Publish immutable remote commits
This commit is contained in:
@@ -102,10 +102,9 @@ restore, status and validation checks, and previous-cache planning.
|
||||
|
||||
Core helpers:
|
||||
|
||||
- `LoadCurrentRunPointer`
|
||||
- `LoadCurrentManifest`
|
||||
- `LoadCurrentState`
|
||||
- `ValidateCurrentStateIdentity`
|
||||
- `RemoteCommitManifest` and `CurrentCommitPointer`
|
||||
|
||||
Typed missing-state errors:
|
||||
|
||||
@@ -133,6 +132,12 @@ Caller policy is intentionally outside artifacts helpers:
|
||||
- spool/cache paths;
|
||||
- S3 session/run/current-state key layout.
|
||||
|
||||
New publication creates run-scoped immutable objects, including
|
||||
`runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole
|
||||
mutable selector is `current/commit-pointer.json`; readers verify its selected
|
||||
commit and declared object generations/checksums. Legacy current-pair loading
|
||||
is confined to `current_state_legacy.go` for migration only.
|
||||
|
||||
Campaign, session, and Narratio run IDs are validated as portable opaque
|
||||
segments at configuration and artifact boundaries before they can be used in a
|
||||
workspace or S3 namespace. Previous-artifact destinations remain typed,
|
||||
|
||||
@@ -16,8 +16,8 @@ Upload run/session outputs to object storage and atomically advance remote curre
|
||||
|
||||
- uploaded invocation record and selected publish outputs;
|
||||
- uploaded durable previous-session cache files when present;
|
||||
- updated remote current manifest; and
|
||||
- remote current-run commit marker, written last.
|
||||
- immutable run-scoped commit manifest; and
|
||||
- current commit pointer, written last.
|
||||
|
||||
Exact remote placement and the operator workflow belong in
|
||||
[Operations](../operations.md#publish-workflow).
|
||||
@@ -40,7 +40,10 @@ Exact remote placement and the operator workflow belong in
|
||||
- selected artifact filter applies to configured artifact sources only.
|
||||
- locked outputs are skipped intentionally (including required ones).
|
||||
- optional missing outputs are skipped; required missing unlocked outputs fail.
|
||||
- writes remote current manifest before current run pointer.
|
||||
- creates one complete immutable source-to-destination mapping before upload;
|
||||
- uploads and verifies every declared immutable object and the commit manifest;
|
||||
- updates `current/commit-pointer.json` exactly once, last; and
|
||||
- does not write the legacy `current/manifest.json` or `current/run_id.txt` pair.
|
||||
|
||||
## Metadata Signals
|
||||
|
||||
@@ -51,12 +54,13 @@ Includes counts/lists for:
|
||||
- skipped optional outputs
|
||||
- skipped unselected outputs
|
||||
- locked outputs
|
||||
- current-state key paths
|
||||
- `current_pointer_written`
|
||||
- remote commit and current-pointer key paths
|
||||
|
||||
## Invariants
|
||||
|
||||
- `current/run_id.txt` is the remote commit marker and is written last.
|
||||
- `current/commit-pointer.json` is the remote commit marker and is written last.
|
||||
- run files, selected outputs, previous-cache files, and the committed session
|
||||
manifest are all declared by an immutable commit under the run prefix.
|
||||
- run and previous uploads contain only manifest-declared regular files opened
|
||||
from verified descriptors; symlinks, special files, replacement races, and
|
||||
undeclared entries are rejected or ignored before uploads begin.
|
||||
|
||||
@@ -55,7 +55,8 @@ lock only while their context remains active, and report a release failure.
|
||||
Automatic post-publish cleanup:
|
||||
|
||||
- only runs when publish actually executed and succeeded;
|
||||
- requires `uploaded=true` and `current_pointer_written=true` metadata;
|
||||
- requires `uploaded=true`, a remote commit key, and a current commit-pointer
|
||||
key in publish metadata;
|
||||
- consumes the resolved cleanup policy described in
|
||||
[Configuration](../config.md);
|
||||
- refuses unsafe deletes (root delete, out-of-root delete, and symlinked
|
||||
|
||||
@@ -200,13 +200,15 @@ Publish commit model:
|
||||
|
||||
- uploads eligible run files under `{session_prefix}/runs/{run_id}/`, excluding
|
||||
audio and the run-local Notarius staging bundle;
|
||||
- uploads configured published outputs, including only explicitly configured
|
||||
extraction lanes;
|
||||
- uploads `previous/**` cache files when present;
|
||||
- writes `current/manifest.json`;
|
||||
- writes `current/run_id.txt` last.
|
||||
- uploads configured published outputs and `previous/**` cache files into the
|
||||
same immutable run scope, including only explicitly configured extraction
|
||||
lanes;
|
||||
- writes `{session_prefix}/runs/{run_id}/commit.json` after all declared
|
||||
immutable objects are uploaded and verified; and
|
||||
- writes `{session_prefix}/current/commit-pointer.json` once, last.
|
||||
|
||||
`current/run_id.txt` is the remote current-state commit marker.
|
||||
`current/commit-pointer.json` is the remote current-state commit marker. It
|
||||
selects exactly one immutable commit, which declares the complete object set.
|
||||
|
||||
## Remote Commit Migration
|
||||
|
||||
@@ -217,10 +219,10 @@ commit by version, checksum, size, and storage generation; committed artifacts
|
||||
are also checksum- and generation-bound. Readers accept this contract now and
|
||||
strictly reject mismatched or unknown data.
|
||||
|
||||
The publish workflow above remains the legacy writer until its planned cutover.
|
||||
Legacy reads are limited to a coherent `current/manifest.json` and
|
||||
`current/run_id.txt` pair; a torn pair is rejected. New remote commit state does
|
||||
not carry local `current_pointer_written` metadata.
|
||||
`current/run_id.txt` pair; a torn pair is rejected. New publication does not
|
||||
write that pair and remote commit state does not carry local
|
||||
`current_pointer_written` metadata.
|
||||
|
||||
## Publish Locks
|
||||
|
||||
|
||||
@@ -167,13 +167,13 @@ contracts belong under [Integrations](../integrations/).
|
||||
## Publish Commit Boundary
|
||||
|
||||
Publish has one explicit remote commit boundary. A remote run becomes current
|
||||
only after Narratio has successfully uploaded the run record, required published
|
||||
outputs, `current/manifest.json`, and finally `current/run_id.txt`.
|
||||
only after Narratio has successfully uploaded its immutable run-scoped objects,
|
||||
the immutable commit manifest, and finally the current commit pointer.
|
||||
|
||||
`current/run_id.txt` is the commit marker and must be written last. Failed,
|
||||
incomplete, skipped, or uncommitted publish attempts must not be presented as
|
||||
current remote state. Publish locks remain authoritative and are not bypassed by
|
||||
a forced run.
|
||||
`current/commit-pointer.json` is the sole mutable selector and must be written
|
||||
exactly once, last. Failed, incomplete, skipped, or uncommitted publish attempts
|
||||
must not be presented as current remote state. Publish locks remain authoritative
|
||||
and are not bypassed by a forced run.
|
||||
|
||||
Automatic local cleanup is permitted only after a successful publish commit,
|
||||
only when explicitly configured, and only through the path-safety guardrails.
|
||||
|
||||
@@ -29,7 +29,7 @@ All stages are pending when this plan is created.
|
||||
| 11 | Make manifest and run identity singular | COR-001, TST-006 | Completed |
|
||||
| 12 | Centralize handled terminal-failure persistence | RSK-001, TST-002, SIM-001, COM-001 | Completed |
|
||||
| 13 | Introduce the immutable remote-commit model and legacy boundary | ARC-003 | Completed |
|
||||
| 14 | Publish through immutable commits and canonical mappings | COR-004, COR-011, DUP-002, TST-004 | Pending |
|
||||
| 14 | Publish through immutable commits and canonical mappings | COR-004, COR-011, DUP-002, TST-004 | Completed |
|
||||
| 15 | Make remote locks generation-safe and harden pagination | RSK-005, RSK-014 | Pending |
|
||||
| 16 | Persist retryable post-commit cleanup state | COR-006, COR-007 | Pending |
|
||||
| 17 | Bind restore/status to a committed snapshot and reject conflicts | COR-008, COR-009, TST-005 | Pending |
|
||||
|
||||
@@ -2,6 +2,8 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -21,6 +23,7 @@ type FakeBackend struct {
|
||||
DownloadErr error
|
||||
UploadErr error
|
||||
ExistsErr error
|
||||
UploadHook func(FakeUploadCall) error
|
||||
}
|
||||
|
||||
// FakeUploadCall captures one upload invocation in call order.
|
||||
@@ -54,6 +57,9 @@ func (f *FakeBackend) SeedObject(obj FakeObject) {
|
||||
obj.Key = key
|
||||
obj.Data = append([]byte(nil), obj.Data...)
|
||||
obj.Metadata = copyMetadata(obj.Metadata)
|
||||
if obj.ETag == "" {
|
||||
obj.ETag = fakeObjectETag(obj.Data)
|
||||
}
|
||||
f.Objects[key] = obj
|
||||
}
|
||||
|
||||
@@ -175,14 +181,20 @@ func (f *FakeBackend) uploadReader(ctx context.Context, source io.Reader, key st
|
||||
}
|
||||
|
||||
normalizedKey := normalizeObjectKey(key)
|
||||
f.Uploads = append(f.Uploads, FakeUploadCall{
|
||||
call := FakeUploadCall{
|
||||
LocalPath: localPath,
|
||||
Key: normalizedKey,
|
||||
Options: UploadOptions{
|
||||
Metadata: copyMetadata(opts.Metadata),
|
||||
ContentType: opts.ContentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
f.Uploads = append(f.Uploads, call)
|
||||
if f.UploadHook != nil {
|
||||
if err := f.UploadHook(call); err != nil {
|
||||
return ObjectInfo{}, err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
obj := FakeObject{
|
||||
Key: normalizedKey,
|
||||
@@ -194,10 +206,16 @@ func (f *FakeBackend) uploadReader(ctx context.Context, source io.Reader, key st
|
||||
return ObjectInfo{
|
||||
Key: normalizedKey,
|
||||
Size: int64(len(data)),
|
||||
ETag: fakeObjectETag(data),
|
||||
LastModified: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fakeObjectETag(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Exists checks object presence.
|
||||
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
@@ -144,11 +144,11 @@ func publishCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool,
|
||||
if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded {
|
||||
return false, "publish did not upload run record"
|
||||
}
|
||||
if pointer, _ := sr.Metadata["current_pointer_written"].(bool); !pointer {
|
||||
return false, "publish did not write current pointer"
|
||||
if strings.TrimSpace(asString(sr.Metadata["remote_commit_key"])) == "" {
|
||||
return false, "publish remote commit key is missing"
|
||||
}
|
||||
if strings.TrimSpace(asString(sr.Metadata["current_run_id_key"])) == "" {
|
||||
return false, "publish current run pointer key is missing"
|
||||
if strings.TrimSpace(asString(sr.Metadata["current_commit_pointer_key"])) == "" {
|
||||
return false, "publish current commit pointer key is missing"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ func (publishSuccessStage) Name() string { return "publish" }
|
||||
func (publishSuccessStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s publishSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
md := map[string]any{
|
||||
"stage": "publish",
|
||||
"uploaded": true,
|
||||
"current_pointer_written": true,
|
||||
"current_run_id_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt",
|
||||
"stage": "publish",
|
||||
"uploaded": true,
|
||||
"remote_commit_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/20260519T010203Z-a1b2c3d4/commit.json",
|
||||
"current_commit_pointer_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/commit-pointer.json",
|
||||
}
|
||||
for k, v := range s.metadata {
|
||||
md[k] = v
|
||||
@@ -130,12 +130,12 @@ func TestPostPublishCleanupNotRunWhenPublishSkipped(t *testing.T) {
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
|
||||
func TestPostPublishCleanupNotRunWhenCommitPointerMissing(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"current_pointer_written": false}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"current_commit_pointer_key": ""}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -216,11 +216,11 @@ func TestPostPublishCleanupNotRunWhenOutputIsMissing(t *testing.T) {
|
||||
assertExists(t, artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID))
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
||||
func TestPostPublishCleanupNotRunWhenCommittedManifestUploadFails(t *testing.T) {
|
||||
cfg, seed, _ := publishStageCleanupFixture(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
failKey := seed.sessionPrefix + "current/manifest.json"
|
||||
failKey := artifacts.S3RunSessionManifestKey(seed.sessionPrefix, seed.runID)
|
||||
|
||||
publishStageImpl, err := stage.Select("publish")
|
||||
if err != nil {
|
||||
@@ -229,19 +229,19 @@ func TestPostPublishCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
|
||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "current manifest") {
|
||||
t.Fatalf("executeStages() error = %v, want current-manifest failure", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "immutable object") {
|
||||
t.Fatalf("executeStages() error = %v, want committed-manifest failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
||||
func TestPostPublishCleanupNotRunWhenCommitPointerUploadFails(t *testing.T) {
|
||||
cfg, seed, _ := publishStageCleanupFixture(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
failKey := seed.sessionPrefix + "current/run_id.txt"
|
||||
failKey := artifacts.S3CurrentCommitPointerKey(seed.sessionPrefix)
|
||||
|
||||
publishStageImpl, err := stage.Select("publish")
|
||||
if err != nil {
|
||||
@@ -250,8 +250,8 @@ func TestPostPublishCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
|
||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "current run pointer") {
|
||||
t.Fatalf("executeStages() error = %v, want current-run-pointer failure", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "current commit pointer") {
|
||||
t.Fatalf("executeStages() error = %v, want current-commit-pointer failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
@@ -259,6 +259,7 @@ func TestPostPublishCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
||||
}
|
||||
|
||||
type cleanupSeed struct {
|
||||
runID string
|
||||
runWorkDir string
|
||||
otherRunDir string
|
||||
spoolAudioDir string
|
||||
@@ -312,6 +313,7 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
|
||||
}
|
||||
|
||||
return cfg, cleanupSeed{
|
||||
runID: runID,
|
||||
runWorkDir: runWorkDir,
|
||||
otherRunDir: otherRunDir,
|
||||
spoolAudioDir: spoolAudioDir,
|
||||
|
||||
@@ -19,6 +19,7 @@ type RemoteArtifactType string
|
||||
const (
|
||||
RemoteArtifactTypeSessionManifest RemoteArtifactType = "session_manifest"
|
||||
RemoteArtifactTypeRunManifest RemoteArtifactType = "run_manifest"
|
||||
RemoteArtifactTypeRunFile RemoteArtifactType = "run_file"
|
||||
RemoteArtifactTypePublishedOutput RemoteArtifactType = "published_output"
|
||||
RemoteArtifactTypePreviousArtifact RemoteArtifactType = "previous_artifact"
|
||||
)
|
||||
@@ -68,15 +69,38 @@ func (m RemoteCommitManifest) Validate() error {
|
||||
return fmt.Errorf("remote commit artifacts are required")
|
||||
}
|
||||
|
||||
destinations := make(map[string]struct{}, len(m.Artifacts))
|
||||
if err := ValidateRemoteArtifactMapping(m.Artifacts); err != nil {
|
||||
return err
|
||||
}
|
||||
for index, artifact := range m.Artifacts {
|
||||
if err := artifact.Validate(); err != nil {
|
||||
return fmt.Errorf("remote commit artifact %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRemoteArtifactMapping verifies that source and destination identities
|
||||
// form one unambiguous immutable-object mapping.
|
||||
func ValidateRemoteArtifactMapping(artifacts []RemoteArtifact) error {
|
||||
if len(artifacts) == 0 {
|
||||
return fmt.Errorf("remote commit artifacts are required")
|
||||
}
|
||||
destinations := make(map[string]struct{}, len(artifacts))
|
||||
sources := make(map[string]struct{}, len(artifacts))
|
||||
for index, artifact := range artifacts {
|
||||
if err := validateRemoteArtifactIdentity(artifact); err != nil {
|
||||
return fmt.Errorf("remote commit artifact %d: %w", index, err)
|
||||
}
|
||||
if _, exists := destinations[artifact.DestinationKey]; exists {
|
||||
return fmt.Errorf("remote commit declares duplicate destination %q", artifact.DestinationKey)
|
||||
}
|
||||
destinations[artifact.DestinationKey] = struct{}{}
|
||||
sourceKey := string(artifact.Type) + "\x00" + artifact.Source
|
||||
if _, exists := sources[sourceKey]; exists {
|
||||
return fmt.Errorf("remote commit declares ambiguous %s source %q", artifact.Type, artifact.Source)
|
||||
}
|
||||
sources[sourceKey] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,16 +122,8 @@ func (m RemoteCommitManifest) ValidateForSessionPrefix(sessionPrefix string) err
|
||||
}
|
||||
|
||||
func (a RemoteArtifact) Validate() error {
|
||||
switch a.Type {
|
||||
case RemoteArtifactTypeSessionManifest, RemoteArtifactTypeRunManifest, RemoteArtifactTypePublishedOutput, RemoteArtifactTypePreviousArtifact:
|
||||
default:
|
||||
return fmt.Errorf("unsupported artifact type %q", a.Type)
|
||||
}
|
||||
if strings.TrimSpace(a.Source) == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
if err := validateRemoteObjectKey(a.DestinationKey); err != nil {
|
||||
return fmt.Errorf("destination key: %w", err)
|
||||
if err := validateRemoteArtifactIdentity(a); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSHA256(a.SHA256); err != nil {
|
||||
return fmt.Errorf("sha256: %w", err)
|
||||
@@ -121,6 +137,21 @@ func (a RemoteArtifact) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteArtifactIdentity(a RemoteArtifact) error {
|
||||
switch a.Type {
|
||||
case RemoteArtifactTypeSessionManifest, RemoteArtifactTypeRunManifest, RemoteArtifactTypeRunFile, RemoteArtifactTypePublishedOutput, RemoteArtifactTypePreviousArtifact:
|
||||
default:
|
||||
return fmt.Errorf("unsupported artifact type %q", a.Type)
|
||||
}
|
||||
if strings.TrimSpace(a.Source) == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
if err := validateRemoteObjectKey(a.DestinationKey); err != nil {
|
||||
return fmt.Errorf("destination key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p CurrentCommitPointer) Validate() error {
|
||||
if p.FormatVersion != RemoteCommitFormatVersion {
|
||||
return fmt.Errorf("unsupported current commit pointer format version %d", p.FormatVersion)
|
||||
|
||||
@@ -64,6 +64,12 @@ func S3RunCommitKey(sessionPrefix, runID string) string {
|
||||
return path.Join(strings.TrimSuffix(S3RunPrefix(sessionPrefix, runID), "/"), config.S3CommitFile)
|
||||
}
|
||||
|
||||
// S3RunSessionManifestKey returns the immutable session-manifest snapshot key for one run.
|
||||
// Format: {session_prefix}/runs/{run_id}/session-manifest.json
|
||||
func S3RunSessionManifestKey(sessionPrefix, runID string) string {
|
||||
return path.Join(strings.TrimSuffix(S3RunPrefix(sessionPrefix, runID), "/"), config.S3SessionSnapshotFile)
|
||||
}
|
||||
|
||||
// S3CurrentCommitPointerKey returns the sole mutable selector for a committed run.
|
||||
// Format: {session_prefix}/current/commit-pointer.json
|
||||
func S3CurrentCommitPointerKey(sessionPrefix string) string {
|
||||
|
||||
@@ -35,6 +35,9 @@ func TestS3KeyConstruction(t *testing.T) {
|
||||
if got, want := S3RunCommitKey(sessionPrefix, runID), wantRunPrefix+"commit.json"; got != want {
|
||||
t.Fatalf("commit key = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := S3RunSessionManifestKey(sessionPrefix, runID), wantRunPrefix+"session-manifest.json"; got != want {
|
||||
t.Fatalf("session manifest key = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
runPointer := S3CurrentRunPointerKey(sessionPrefix)
|
||||
if runPointer != "dnd/campaigns/forsaken/sessions/2026-04-19/current/run_id.txt" {
|
||||
|
||||
@@ -83,14 +83,15 @@ const (
|
||||
PathTranscriptFinal = artifactmodel.TranscriptPathFinal
|
||||
PathTranscriptFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||
|
||||
S3CampaignsSegment = "campaigns"
|
||||
S3SessionsSegment = "sessions"
|
||||
S3RunsSegment = "runs"
|
||||
S3CurrentSegment = "current"
|
||||
S3ManifestFile = "manifest.json"
|
||||
S3RunIDFile = "run_id.txt"
|
||||
S3CommitFile = "commit.json"
|
||||
S3CommitPointerFile = "commit-pointer.json"
|
||||
S3CampaignsSegment = "campaigns"
|
||||
S3SessionsSegment = "sessions"
|
||||
S3RunsSegment = "runs"
|
||||
S3CurrentSegment = "current"
|
||||
S3ManifestFile = "manifest.json"
|
||||
S3RunIDFile = "run_id.txt"
|
||||
S3CommitFile = "commit.json"
|
||||
S3CommitPointerFile = "commit-pointer.json"
|
||||
S3SessionSnapshotFile = "session-manifest.json"
|
||||
)
|
||||
|
||||
// DefaultPublishOutputs defines the default publish output rules.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
@@ -59,22 +59,20 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
if publishDisabled(env) {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"publish_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
"current_pointer_written": false,
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"publish_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if publishRunUploadDisabled(env) {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"upload_run_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
"current_pointer_written": false,
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"upload_run_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -86,10 +84,11 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("publish: remote object store backend is required when publish run upload is enabled")
|
||||
}
|
||||
|
||||
runRoot, err := resolvePublishRunRoot(env, m)
|
||||
runManifestPath, err := resolvePublishRunManifestPath(env, m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: resolve run root: %w", err)
|
||||
return nil, fmt.Errorf("publish: resolve run manifest path: %w", err)
|
||||
}
|
||||
runRoot := filepath.Dir(runManifestPath)
|
||||
runRootInfo, err := os.Lstat(runRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: run root %q: %w", runRoot, err)
|
||||
@@ -115,7 +114,7 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("publish: run id is required")
|
||||
}
|
||||
|
||||
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, "manifest.json")
|
||||
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, filepath.Base(runManifestPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: open run manifest: %w", err)
|
||||
}
|
||||
@@ -170,72 +169,83 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
env.Config.Pipeline.Publish.Outputs,
|
||||
env.Config.Pipeline.Publish.Locks,
|
||||
env.SelectedArtifactKeys,
|
||||
sessionPrefix,
|
||||
runPrefix,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: resolve publish output rules: %w", err)
|
||||
}
|
||||
runUploaded := make([]string, 0, len(runFiles))
|
||||
for index, file := range runFiles {
|
||||
key := artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath)
|
||||
if _, err := uploader.UploadReader(ctx, runSources[index], key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload run file %q to %q: %w", file.RelativePath, key, err)
|
||||
}
|
||||
runUploaded = append(runUploaded, file.RelativePath)
|
||||
}
|
||||
|
||||
publishedUploaded := make([]string, 0, len(publishOutputs))
|
||||
for _, publishedOutput := range publishOutputs {
|
||||
key := artifacts.S3PublishedOutputKey(sessionPrefix, publishedOutput.Dest)
|
||||
if _, err := env.ObjectStore.Upload(ctx, publishedOutput.LocalPath, key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload published output source %q to %q: %w", publishedOutput.Source, key, err)
|
||||
}
|
||||
publishedUploaded = append(publishedUploaded, publishedOutput.Dest)
|
||||
}
|
||||
runUploaded := publishUploadPaths(runFiles)
|
||||
publishedUploaded := publishOutputPaths(publishOutputs)
|
||||
previousUploaded := publishUploadPaths(previousFiles)
|
||||
commitKey := artifacts.S3RunCommitKey(sessionPrefix, runID)
|
||||
pointerKey := artifacts.S3CurrentCommitPointerKey(sessionPrefix)
|
||||
|
||||
previousUploaded := make([]string, 0, len(previousFiles))
|
||||
for index, file := range previousFiles {
|
||||
key := artifacts.S3PublishedOutputKey(sessionPrefix, file.RelativePath)
|
||||
if _, err := uploader.UploadReader(ctx, previousSources[index], key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload previous file %q to %q: %w", file.RelativePath, key, err)
|
||||
}
|
||||
previousUploaded = append(previousUploaded, file.RelativePath)
|
||||
transfers, err := buildPublishTransferPlan(runPrefix, runFiles, runSources, previousFiles, previousSources, publishOutputs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build immutable upload mapping: %w", err)
|
||||
}
|
||||
defer func() { closePublishTransfers(transfers) }()
|
||||
|
||||
currentManifestKey, currentRunPointerKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
|
||||
manifestTempPath, err := writeCurrentManifestSnapshot(m, publishMetadataPreview(
|
||||
manifestTempPath, err := writeCommittedSessionManifest(m, publishMetadata(
|
||||
bucket,
|
||||
runPrefix,
|
||||
sessionPrefix,
|
||||
runUploaded,
|
||||
publishedUploaded,
|
||||
previousUploaded,
|
||||
skippedOptionalOutputs,
|
||||
skippedUnselectedOutputs,
|
||||
lockedOutputs,
|
||||
currentManifestKey,
|
||||
commitKey,
|
||||
pointerKey,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build current manifest snapshot: %w", err)
|
||||
return nil, fmt.Errorf("publish: build committed session manifest: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(manifestTempPath) }()
|
||||
|
||||
if _, err := env.ObjectStore.Upload(ctx, manifestTempPath, currentManifestKey, storage.UploadOptions{
|
||||
ContentType: "application/json",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload current manifest to %q: %w", currentManifestKey, err)
|
||||
if err := appendCommittedSessionManifest(&transfers, manifestTempPath, artifacts.S3RunSessionManifestKey(sessionPrefix, runID)); err != nil {
|
||||
return nil, fmt.Errorf("publish: add committed session manifest: %w", err)
|
||||
}
|
||||
if err := validatePublishTransferPlan(transfers, commitKey); err != nil {
|
||||
return nil, fmt.Errorf("publish: validate immutable upload mapping: %w", err)
|
||||
}
|
||||
|
||||
runIDTempPath, err := writeCurrentRunIDPointer(runID)
|
||||
commit := artifacts.RemoteCommitManifest{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: strings.TrimSpace(m.Campaign),
|
||||
SessionID: strings.TrimSpace(m.SessionID),
|
||||
RunID: runID,
|
||||
Artifacts: make([]artifacts.RemoteArtifact, 0, len(transfers)),
|
||||
}
|
||||
for index := range transfers {
|
||||
artifact, err := uploadVerifiedPublishTransfer(ctx, env.ObjectStore, uploader, &transfers[index])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: upload immutable object %q: %w", transfers[index].Artifact.DestinationKey, err)
|
||||
}
|
||||
commit.Artifacts = append(commit.Artifacts, artifact)
|
||||
}
|
||||
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build current run id pointer: %w", err)
|
||||
return nil, fmt.Errorf("publish: encode immutable commit: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(runIDTempPath) }()
|
||||
|
||||
if _, err := env.ObjectStore.Upload(ctx, runIDTempPath, currentRunPointerKey, storage.UploadOptions{
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload current run pointer to %q: %w", currentRunPointerKey, err)
|
||||
commitInfo, err := uploadVerifiedPublishBytes(ctx, env.ObjectStore, uploader, commitKey, commitData, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: upload immutable commit %q: %w", commitKey, err)
|
||||
}
|
||||
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: commit.Campaign,
|
||||
SessionID: commit.SessionID,
|
||||
RunID: commit.RunID,
|
||||
CommitKey: commitKey,
|
||||
CommitSHA256: publishSHA256(commitData),
|
||||
CommitSize: int64(len(commitData)),
|
||||
CommitGeneration: commitInfo.ETag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: encode current commit pointer: %w", err)
|
||||
}
|
||||
if _, err := uploader.UploadReader(ctx, bytes.NewReader(pointerData), pointerKey, storage.UploadOptions{ContentType: "application/json"}); err != nil {
|
||||
return nil, fmt.Errorf("publish: update current commit pointer %q: %w", pointerKey, err)
|
||||
}
|
||||
|
||||
return &StageResult{
|
||||
@@ -254,9 +264,8 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
"skipped_unselected_outputs": skippedUnselectedOutputMetadata(skippedUnselectedOutputs),
|
||||
"locked_output_count": len(lockedOutputs),
|
||||
"locked_outputs": lockedOutputMetadata(lockedOutputs),
|
||||
"current_manifest_key": currentManifestKey,
|
||||
"current_run_id_key": currentRunPointerKey,
|
||||
"current_pointer_written": true,
|
||||
"remote_commit_key": commitKey,
|
||||
"current_commit_pointer_key": pointerKey,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
@@ -270,6 +279,12 @@ type publishOutput struct {
|
||||
Provenance string
|
||||
}
|
||||
|
||||
type publishTransfer struct {
|
||||
Artifact artifacts.RemoteArtifact
|
||||
Source *os.File
|
||||
TemporaryPath string
|
||||
}
|
||||
|
||||
type publishLockedOutput struct {
|
||||
Source string
|
||||
Dest string
|
||||
@@ -286,6 +301,292 @@ type publishSkippedUnselectedOutput struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
func buildPublishTransferPlan(
|
||||
runPrefix string,
|
||||
runFiles []publishUploadFile,
|
||||
runSources []*os.File,
|
||||
previousFiles []publishUploadFile,
|
||||
previousSources []*os.File,
|
||||
publishOutputs []publishOutput,
|
||||
) ([]publishTransfer, error) {
|
||||
if len(runFiles) != len(runSources) || len(previousFiles) != len(previousSources) {
|
||||
return nil, fmt.Errorf("verified publish sources do not match their declared files")
|
||||
}
|
||||
transfers := make([]publishTransfer, 0, len(runFiles)+len(previousFiles)+len(publishOutputs))
|
||||
add := func(artifact artifacts.RemoteArtifact, source *os.File) error {
|
||||
if source == nil {
|
||||
return fmt.Errorf("source for %q is required", artifact.DestinationKey)
|
||||
}
|
||||
transfers = append(transfers, publishTransfer{Artifact: artifact, Source: source})
|
||||
return nil
|
||||
}
|
||||
for index, file := range runFiles {
|
||||
artifactType := artifacts.RemoteArtifactTypeRunFile
|
||||
source := "run:" + file.RelativePath
|
||||
if file.RelativePath == config.S3ManifestFile {
|
||||
artifactType = artifacts.RemoteArtifactTypeRunManifest
|
||||
source = "run.manifest"
|
||||
}
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifactType,
|
||||
Source: source,
|
||||
DestinationKey: artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath),
|
||||
}, runSources[index]); err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for index, file := range previousFiles {
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypePreviousArtifact,
|
||||
Source: "previous:" + file.SourceRelativePath,
|
||||
DestinationKey: artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath),
|
||||
}, previousSources[index]); err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, output := range publishOutputs {
|
||||
source, err := openPublishOutputSource(output.LocalPath)
|
||||
if err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, fmt.Errorf("open output source %q: %w", output.Source, err)
|
||||
}
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypePublishedOutput,
|
||||
Source: output.Source,
|
||||
DestinationKey: publishOutputRemoteKey(runPrefix, output.Dest),
|
||||
}, source); err != nil {
|
||||
_ = source.Close()
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(transfers, func(i, j int) bool {
|
||||
return transfers[i].Artifact.DestinationKey < transfers[j].Artifact.DestinationKey
|
||||
})
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
func appendCommittedSessionManifest(transfers *[]publishTransfer, localPath, destinationKey string) error {
|
||||
if transfers == nil {
|
||||
return fmt.Errorf("transfer plan is required")
|
||||
}
|
||||
source, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*transfers = append(*transfers, publishTransfer{
|
||||
Artifact: artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypeSessionManifest,
|
||||
Source: "session.manifest",
|
||||
DestinationKey: destinationKey,
|
||||
},
|
||||
Source: source,
|
||||
TemporaryPath: localPath,
|
||||
})
|
||||
sort.Slice(*transfers, func(i, j int) bool {
|
||||
return (*transfers)[i].Artifact.DestinationKey < (*transfers)[j].Artifact.DestinationKey
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePublishTransferPlan(transfers []publishTransfer, commitKey string) error {
|
||||
artifactsToUpload := make([]artifacts.RemoteArtifact, 0, len(transfers))
|
||||
for _, transfer := range transfers {
|
||||
if transfer.Artifact.DestinationKey == commitKey {
|
||||
return fmt.Errorf("artifact destination %q is reserved for the immutable commit", commitKey)
|
||||
}
|
||||
artifactsToUpload = append(artifactsToUpload, transfer.Artifact)
|
||||
}
|
||||
return artifacts.ValidateRemoteArtifactMapping(artifactsToUpload)
|
||||
}
|
||||
|
||||
func openPublishOutputSource(localPath string) (*os.File, error) {
|
||||
path := filepath.Clean(strings.TrimSpace(localPath))
|
||||
if path == "" || path == "." {
|
||||
return nil, fmt.Errorf("local path is required")
|
||||
}
|
||||
return fileops.OpenConfinedRegularFile(filepath.Dir(path), filepath.Base(path))
|
||||
}
|
||||
|
||||
func publishOutputRemoteKey(runPrefix, destination string) string {
|
||||
return artifacts.S3RunRelativeDestinationKey(runPrefix, filepath.ToSlash(filepath.Join("published", destination)))
|
||||
}
|
||||
|
||||
func uploadVerifiedPublishTransfer(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
uploader storage.ReaderUploader,
|
||||
transfer *publishTransfer,
|
||||
) (artifacts.RemoteArtifact, error) {
|
||||
if transfer == nil || transfer.Source == nil {
|
||||
return artifacts.RemoteArtifact{}, fmt.Errorf("transfer source is required")
|
||||
}
|
||||
if err := preparePublishTransfer(transfer); err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
if existing, found, err := verifyExistingImmutableObject(ctx, store, transfer.Artifact.DestinationKey, transfer.Artifact.SHA256, transfer.Artifact.Size); err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
} else if found {
|
||||
transfer.Artifact.Generation = existing.ETag
|
||||
return transfer.Artifact, nil
|
||||
}
|
||||
info, err := uploader.UploadReader(ctx, transfer.Source, transfer.Artifact.DestinationKey, storage.UploadOptions{})
|
||||
if err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
verified, err := verifyPublishedObject(ctx, store, transfer.Artifact.DestinationKey, transfer.Artifact.Size, info.ETag)
|
||||
if err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
transfer.Artifact.Generation = verified.ETag
|
||||
return transfer.Artifact, nil
|
||||
}
|
||||
|
||||
func uploadVerifiedPublishBytes(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
uploader storage.ReaderUploader,
|
||||
key string,
|
||||
data []byte,
|
||||
contentType string,
|
||||
) (storage.ObjectInfo, error) {
|
||||
checksum := publishSHA256(data)
|
||||
if existing, found, err := verifyExistingImmutableObject(ctx, store, key, checksum, int64(len(data))); err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
} else if found {
|
||||
return existing, nil
|
||||
}
|
||||
info, err := uploader.UploadReader(ctx, bytes.NewReader(data), key, storage.UploadOptions{ContentType: contentType})
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
return verifyPublishedObject(ctx, store, key, int64(len(data)), info.ETag)
|
||||
}
|
||||
|
||||
func verifyExistingImmutableObject(ctx context.Context, store storage.ObjectStore, key, wantSHA256 string, wantSize int64) (storage.ObjectInfo, bool, error) {
|
||||
exists, err := store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("check immutable object %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return storage.ObjectInfo{}, false, nil
|
||||
}
|
||||
path, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-verify-immutable-*.bin")
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("download existing immutable object %q: %w", key, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("open existing immutable object %q: %w", key, err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
size, copyErr := io.Copy(hash, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("checksum existing immutable object %q: %w", key, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("close existing immutable object %q: %w", key, closeErr)
|
||||
}
|
||||
if size != wantSize || hex.EncodeToString(hash.Sum(nil)) != wantSHA256 {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("immutable object %q already exists with different content", key)
|
||||
}
|
||||
info, err := verifyPublishedObject(ctx, store, key, wantSize, "")
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, err
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func preparePublishTransfer(transfer *publishTransfer) error {
|
||||
if _, err := transfer.Source.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
info, err := transfer.Source.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("source %q is not a regular file", transfer.Artifact.Source)
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, transfer.Source); err != nil {
|
||||
return fmt.Errorf("checksum source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
if _, err := transfer.Source.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind source %q after checksum: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
transfer.Artifact.Size = info.Size()
|
||||
transfer.Artifact.SHA256 = hex.EncodeToString(hash.Sum(nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyPublishedObject(ctx context.Context, store storage.ObjectStore, key string, wantSize int64, uploadedGeneration string) (storage.ObjectInfo, error) {
|
||||
objects, err := store.List(ctx, key)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("list uploaded object %q: %w", key, err)
|
||||
}
|
||||
var found *storage.ObjectInfo
|
||||
for _, object := range objects {
|
||||
if object.Key != key {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q is ambiguous", key)
|
||||
}
|
||||
copy := object
|
||||
found = ©
|
||||
}
|
||||
if found == nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q is missing", key)
|
||||
}
|
||||
if found.Size != wantSize {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q size is %d, want %d", key, found.Size, wantSize)
|
||||
}
|
||||
if strings.TrimSpace(found.ETag) == "" {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q has no generation", key)
|
||||
}
|
||||
if strings.TrimSpace(uploadedGeneration) != "" && found.ETag != uploadedGeneration {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q generation changed during verification", key)
|
||||
}
|
||||
return *found, nil
|
||||
}
|
||||
|
||||
func closePublishTransfers(transfers []publishTransfer) {
|
||||
for _, transfer := range transfers {
|
||||
if transfer.Source != nil {
|
||||
_ = transfer.Source.Close()
|
||||
}
|
||||
if transfer.TemporaryPath != "" {
|
||||
_ = os.Remove(transfer.TemporaryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishUploadPaths(files []publishUploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.RelativePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func publishOutputPaths(outputs []publishOutput) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.Dest)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func publishSHA256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func publishDisabled(env *Env) bool {
|
||||
cfg := env.Config.Pipeline.Publish
|
||||
if cfg == nil {
|
||||
@@ -318,7 +619,7 @@ func validatePublishPrerequisites(m *manifest.Manifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePublishRunRoot(env *Env, m *manifest.Manifest) (string, error) {
|
||||
func resolvePublishRunManifestPath(env *Env, m *manifest.Manifest) (string, error) {
|
||||
sessionID := strings.TrimSpace(env.Config.Session.SessionID)
|
||||
if sessionID == "" && m != nil {
|
||||
sessionID = strings.TrimSpace(m.SessionID)
|
||||
@@ -338,13 +639,13 @@ func resolvePublishRunRoot(env *Env, m *manifest.Manifest) (string, error) {
|
||||
return "", fmt.Errorf("run id is required")
|
||||
}
|
||||
|
||||
canonical := filepath.Clean(artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID))
|
||||
canonicalExists, err := directoryExists(canonical)
|
||||
canonical := filepath.Clean(artifacts.SessionRunManifestPathForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID))
|
||||
canonicalExists, err := directoryExists(filepath.Dir(canonical))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check canonical run root %q: %w", canonical, err)
|
||||
return "", fmt.Errorf("check canonical run manifest directory %q: %w", filepath.Dir(canonical), err)
|
||||
}
|
||||
if !canonicalExists {
|
||||
return "", fmt.Errorf("run root not found for campaign %q session %q run %q at canonical path %q", campaign, sessionID, runID, canonical)
|
||||
return "", fmt.Errorf("run manifest directory not found for campaign %q session %q run %q at canonical path %q", campaign, sessionID, runID, filepath.Dir(canonical))
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
@@ -387,7 +688,7 @@ func resolvePublishOutputs(
|
||||
rules []config.PublishOutputRule,
|
||||
locks []config.PublishLockRule,
|
||||
selectedArtifactKeys []string,
|
||||
sessionPrefix string,
|
||||
runPrefix string,
|
||||
) ([]publishOutput, []string, []publishSkippedUnselectedOutput, []publishLockedOutput, error) {
|
||||
out := make([]publishOutput, 0, len(rules))
|
||||
skippedOptionalOutputs := make([]string, 0)
|
||||
@@ -423,7 +724,7 @@ func resolvePublishOutputs(
|
||||
lockedOutputs = append(lockedOutputs, publishLockedOutput{
|
||||
Source: source,
|
||||
Dest: dest,
|
||||
RemoteKey: artifacts.S3PublishedOutputKey(sessionPrefix, dest),
|
||||
RemoteKey: publishOutputRemoteKey(runPrefix, dest),
|
||||
Reason: strings.TrimSpace(lock.Reason),
|
||||
Required: required,
|
||||
})
|
||||
@@ -442,7 +743,7 @@ func resolvePublishOutputs(
|
||||
lockedOutputs = append(lockedOutputs, publishLockedOutput{
|
||||
Source: source,
|
||||
Dest: dest,
|
||||
RemoteKey: artifacts.S3PublishedOutputKey(sessionPrefix, dest),
|
||||
RemoteKey: publishOutputRemoteKey(runPrefix, dest),
|
||||
Reason: strings.TrimSpace(lock.Reason),
|
||||
Required: required,
|
||||
LocalPath: resolved.Path,
|
||||
@@ -810,7 +1111,7 @@ func directoryExists(path string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[string]any) (string, error) {
|
||||
func writeCommittedSessionManifest(m *manifest.Manifest, publishMetadata map[string]any) (string, error) {
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("manifest is required")
|
||||
}
|
||||
@@ -840,8 +1141,7 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
clone.Stages[name] = &stageCopy
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
clone.MarkStageSucceeded("publish", now, nil)
|
||||
clone.MarkStageSucceeded("publish", clone.UpdatedAt, nil)
|
||||
if sr := clone.Stages["publish"]; sr != nil {
|
||||
sr.Metadata = publishMetadata
|
||||
}
|
||||
@@ -852,7 +1152,7 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp("", "narratio-current-manifest-*.json")
|
||||
tmp, err := os.CreateTemp("", "narratio-committed-session-manifest-*.json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
@@ -867,31 +1167,16 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func writeCurrentRunIDPointer(runID string) (string, error) {
|
||||
tmp, err := os.CreateTemp("", "narratio-current-run-id-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
if _, err := tmp.WriteString(runID + "\n"); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("write temp run id pointer: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temp run id pointer: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func publishMetadataPreview(
|
||||
bucket, runPrefix, sessionPrefix string,
|
||||
func publishMetadata(
|
||||
bucket, runPrefix string,
|
||||
runUploaded []string,
|
||||
publishedUploaded []string,
|
||||
previousUploaded []string,
|
||||
skippedOptionalOutputs []string,
|
||||
skippedUnselectedOutputs []publishSkippedUnselectedOutput,
|
||||
lockedOutputs []publishLockedOutput,
|
||||
currentManifestKey string,
|
||||
commitKey string,
|
||||
pointerKey string,
|
||||
) map[string]any {
|
||||
return map[string]any{
|
||||
"stage": "publish",
|
||||
@@ -908,9 +1193,8 @@ func publishMetadataPreview(
|
||||
"skipped_unselected_outputs": skippedUnselectedOutputMetadata(skippedUnselectedOutputs),
|
||||
"locked_output_count": len(lockedOutputs),
|
||||
"locked_outputs": lockedOutputMetadata(lockedOutputs),
|
||||
"current_manifest_key": currentManifestKey,
|
||||
"current_run_id_key": artifacts.S3CurrentRunPointerKey(sessionPrefix),
|
||||
"current_pointer_written": false,
|
||||
"remote_commit_key": commitKey,
|
||||
"current_commit_pointer_key": pointerKey,
|
||||
"audio_upload_skipped": true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -127,8 +126,8 @@ func TestPublishUploadsRunRecordPublishedOutputsAndCurrentPointer(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
trimmedKey := sessionPrefix + "transcripts/final.trimmed.json"
|
||||
recapKey := sessionPrefix + "artifacts/session_recap.md"
|
||||
trimmedKey := publishOutputRemoteKey(runPrefix, "transcripts/final.trimmed.json")
|
||||
recapKey := publishOutputRemoteKey(runPrefix, "artifacts/session_recap.md")
|
||||
if _, ok := fake.Objects[trimmedKey]; !ok {
|
||||
t.Fatalf("missing published output key %q", trimmedKey)
|
||||
}
|
||||
@@ -136,16 +135,20 @@ func TestPublishUploadsRunRecordPublishedOutputsAndCurrentPointer(t *testing.T)
|
||||
t.Fatalf("missing published output key %q", recapKey)
|
||||
}
|
||||
|
||||
currentManifestKey := sessionPrefix + "current/manifest.json"
|
||||
currentRunIDKey := sessionPrefix + "current/run_id.txt"
|
||||
if _, ok := fake.Objects[currentManifestKey]; !ok {
|
||||
t.Fatalf("missing current manifest key %q", currentManifestKey)
|
||||
commitKey := artifacts.S3RunCommitKey(sessionPrefix, m.RunID)
|
||||
pointerKey := artifacts.S3CurrentCommitPointerKey(sessionPrefix)
|
||||
if _, ok := fake.Objects[commitKey]; !ok {
|
||||
t.Fatalf("missing immutable commit key %q", commitKey)
|
||||
}
|
||||
if _, ok := fake.Objects[currentRunIDKey]; !ok {
|
||||
t.Fatalf("missing current run pointer key %q", currentRunIDKey)
|
||||
if _, ok := fake.Objects[pointerKey]; !ok {
|
||||
t.Fatalf("missing current commit pointer key %q", pointerKey)
|
||||
}
|
||||
if got := string(fake.Objects[currentRunIDKey].Data); got != m.RunID+"\n" {
|
||||
t.Fatalf("run pointer contents = %q, want %q", got, m.RunID+"\\n")
|
||||
pointer, err := artifacts.DecodeCurrentCommitPointer(fake.Objects[pointerKey].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCurrentCommitPointer() error = %v", err)
|
||||
}
|
||||
if pointer.CommitKey != commitKey || pointer.RunID != m.RunID {
|
||||
t.Fatalf("pointer = %#v, want selected commit %q", pointer, commitKey)
|
||||
}
|
||||
|
||||
audioKey := runPrefix + "audio/speaker.flac"
|
||||
@@ -157,12 +160,12 @@ func TestPublishUploadsRunRecordPublishedOutputsAndCurrentPointer(t *testing.T)
|
||||
if len(uploads) == 0 {
|
||||
t.Fatal("expected uploads")
|
||||
}
|
||||
if uploads[len(uploads)-1].Key != currentRunIDKey {
|
||||
t.Fatalf("last upload key = %q, want current run pointer key %q", uploads[len(uploads)-1].Key, currentRunIDKey)
|
||||
if uploads[len(uploads)-1].Key != pointerKey {
|
||||
t.Fatalf("last upload key = %q, want current commit pointer key %q", uploads[len(uploads)-1].Key, pointerKey)
|
||||
}
|
||||
|
||||
if result.Metadata["current_pointer_written"] != true {
|
||||
t.Fatalf("metadata = %#v, want current_pointer_written=true", result.Metadata)
|
||||
if result.Metadata["current_commit_pointer_key"] != pointerKey {
|
||||
t.Fatalf("metadata = %#v, want current commit pointer key", result.Metadata)
|
||||
}
|
||||
if result.Metadata["published_files_uploaded"] != 2 {
|
||||
t.Fatalf("metadata published_files_uploaded = %#v, want 2", result.Metadata["published_files_uploaded"])
|
||||
@@ -195,8 +198,8 @@ func TestPublishUploadsPreviousCacheWhenPresent(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
previousManifestKey := m.S3SessionPrefix + "previous/manifest.json"
|
||||
previousRecapKey := m.S3SessionPrefix + "previous/artifacts/session_recap.md"
|
||||
previousManifestKey := artifacts.S3RunRelativeDestinationKey(m.S3RunPrefix, "previous/manifest.json")
|
||||
previousRecapKey := artifacts.S3RunRelativeDestinationKey(m.S3RunPrefix, "previous/artifacts/session_recap.md")
|
||||
if _, ok := fake.Objects[previousManifestKey]; !ok {
|
||||
t.Fatalf("missing published previous manifest key %q", previousManifestKey)
|
||||
}
|
||||
@@ -405,10 +408,10 @@ func TestPublishUsesCustomOutputRules(t *testing.T) {
|
||||
}
|
||||
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"published/trimmed.json"]; !ok {
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "published/trimmed.json")]; !ok {
|
||||
t.Fatalf("missing custom published trimmed key")
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"published/recap.md"]; !ok {
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "published/recap.md")]; !ok {
|
||||
t.Fatalf("missing custom published recap key")
|
||||
}
|
||||
}
|
||||
@@ -427,7 +430,7 @@ func TestPublishUploadsExplicitExtractionAndPreservesManifestMetadata(t *testing
|
||||
}
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
durableBundleRoot := filepath.Dir(filepath.Dir(lanePath))
|
||||
publishedKey := m.S3SessionPrefix + "artifacts/encounters.json"
|
||||
publishedKey := publishOutputRemoteKey(m.S3RunPrefix, "artifacts/encounters.json")
|
||||
if got := string(fake.Objects[publishedKey].Data); got != `{"encounters":[]}` {
|
||||
t.Fatalf("published extraction = %q", got)
|
||||
}
|
||||
@@ -448,13 +451,13 @@ func TestPublishUploadsExplicitExtractionAndPreservesManifestMetadata(t *testing
|
||||
if result.Metadata["published_files_uploaded"] != 1 {
|
||||
t.Fatalf("published_files_uploaded = %#v, want 1", result.Metadata["published_files_uploaded"])
|
||||
}
|
||||
if uploads := fake.Uploads; len(uploads) == 0 || uploads[len(uploads)-1].Key != m.S3SessionPrefix+"current/run_id.txt" {
|
||||
if uploads := fake.Uploads; len(uploads) == 0 || uploads[len(uploads)-1].Key != artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix) {
|
||||
t.Fatalf("last upload = %#v, want current run pointer", uploads)
|
||||
}
|
||||
|
||||
var current manifest.Manifest
|
||||
if err := json.Unmarshal(fake.Objects[m.S3SessionPrefix+"current/manifest.json"].Data, ¤t); err != nil {
|
||||
t.Fatalf("unmarshal current manifest: %v", err)
|
||||
if err := json.Unmarshal(fake.Objects[artifacts.S3RunSessionManifestKey(m.S3SessionPrefix, m.RunID)].Data, ¤t); err != nil {
|
||||
t.Fatalf("unmarshal committed session manifest: %v", err)
|
||||
}
|
||||
extractRecord := current.Stages["extract"]
|
||||
if extractRecord == nil || len(extractRecord.Outputs) != 2 {
|
||||
@@ -528,7 +531,7 @@ func TestPublishArtifactSelectionDoesNotFilterExtractionOutputs(t *testing.T) {
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := env.ObjectStore.(*storage.FakeBackend).Objects[m.S3SessionPrefix+"artifacts/encounters.json"]; !ok {
|
||||
if _, ok := env.ObjectStore.(*storage.FakeBackend).Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/encounters.json")]; !ok {
|
||||
t.Fatal("explicit extraction output was filtered by Scriptorium artifact selection")
|
||||
}
|
||||
}
|
||||
@@ -565,10 +568,10 @@ func TestPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"transcripts/final.trimmed.json"]; !ok {
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "transcripts/final.trimmed.json")]; !ok {
|
||||
t.Fatalf("missing built-in published trimmed key")
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok {
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")]; ok {
|
||||
t.Fatalf("unexpected unselected recap published output upload")
|
||||
}
|
||||
skipped := result.Metadata["skipped_unselected_outputs"].([]map[string]any)
|
||||
@@ -581,7 +584,7 @@ func TestPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.T) {
|
||||
if result.Metadata["locked_output_count"] != 0 {
|
||||
t.Fatalf("locked_output_count = %#v, want 0", result.Metadata["locked_output_count"])
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; !ok {
|
||||
if _, ok := fake.Objects[artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix)]; !ok {
|
||||
t.Fatalf("missing current pointer")
|
||||
}
|
||||
}
|
||||
@@ -616,7 +619,7 @@ func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"artifacts/session_recap.md"]; ok {
|
||||
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")]; ok {
|
||||
t.Fatalf("unexpected locked recap published output upload")
|
||||
}
|
||||
if result.Metadata["locked_output_count"] != 1 {
|
||||
@@ -665,11 +668,11 @@ func TestPublishSkipsLockedRequiredOutputAndCommits(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
|
||||
trimmedKey := publishOutputRemoteKey(m.S3RunPrefix, "transcripts/final.trimmed.json")
|
||||
if _, ok := fake.Objects[trimmedKey]; ok {
|
||||
t.Fatalf("locked published output key %q should not be uploaded", trimmedKey)
|
||||
}
|
||||
recapKey := m.S3SessionPrefix + "artifacts/session_recap.md"
|
||||
recapKey := publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")
|
||||
if _, ok := fake.Objects[recapKey]; !ok {
|
||||
t.Fatalf("unlocked published output key %q should be uploaded", recapKey)
|
||||
}
|
||||
@@ -678,9 +681,9 @@ func TestPublishSkipsLockedRequiredOutputAndCommits(t *testing.T) {
|
||||
t.Fatalf("run-local locked source output %q should still be uploaded", runTrimmedKey)
|
||||
}
|
||||
|
||||
currentRunIDKey := m.S3SessionPrefix + "current/run_id.txt"
|
||||
if len(fake.Uploads) == 0 || fake.Uploads[len(fake.Uploads)-1].Key != currentRunIDKey {
|
||||
t.Fatalf("last upload = %#v, want current run pointer %q", fake.Uploads, currentRunIDKey)
|
||||
pointerKey := artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix)
|
||||
if len(fake.Uploads) == 0 || fake.Uploads[len(fake.Uploads)-1].Key != pointerKey {
|
||||
t.Fatalf("last upload = %#v, want current commit pointer %q", fake.Uploads, pointerKey)
|
||||
}
|
||||
|
||||
if result.Metadata["published_files_uploaded"] != 1 {
|
||||
@@ -703,7 +706,7 @@ func TestPublishSkipsLockedRequiredOutputAndCommits(t *testing.T) {
|
||||
t.Fatalf("locked published output metadata = %#v", locked[0])
|
||||
}
|
||||
|
||||
currentManifestKey := m.S3SessionPrefix + "current/manifest.json"
|
||||
currentManifestKey := artifacts.S3RunSessionManifestKey(m.S3SessionPrefix, m.RunID)
|
||||
var current map[string]any
|
||||
if err := json.Unmarshal(fake.Objects[currentManifestKey].Data, ¤t); err != nil {
|
||||
t.Fatalf("unmarshal current manifest: %v", err)
|
||||
@@ -735,12 +738,12 @@ func TestPublishLockedRequiredMissingOutputSucceeds(t *testing.T) {
|
||||
}
|
||||
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
mergedKey := m.S3SessionPrefix + "transcripts/base.json"
|
||||
mergedKey := publishOutputRemoteKey(m.S3RunPrefix, "transcripts/base.json")
|
||||
if _, ok := fake.Objects[mergedKey]; ok {
|
||||
t.Fatalf("locked missing published output key %q should not be uploaded", mergedKey)
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; !ok {
|
||||
t.Fatalf("current run pointer should be written for locked missing output")
|
||||
if _, ok := fake.Objects[artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix)]; !ok {
|
||||
t.Fatalf("current commit pointer should be written for locked missing output")
|
||||
}
|
||||
locked := result.Metadata["locked_outputs"].([]map[string]any)
|
||||
if len(locked) != 1 {
|
||||
@@ -757,7 +760,7 @@ func TestPublishLockDoesNotOverwriteExistingOutput(t *testing.T) {
|
||||
{Source: "narratio.transcript.final_trimmed", Reason: "already published"},
|
||||
}
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
|
||||
trimmedKey := publishOutputRemoteKey(m.S3RunPrefix, "transcripts/final.trimmed.json")
|
||||
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte("previously published\n")})
|
||||
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
@@ -797,49 +800,145 @@ func TestPublishFailsWhenCanonicalRunRootMissing(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected missing run-root error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run root not found") {
|
||||
t.Fatalf("error = %v, want missing run-root error", err)
|
||||
if !strings.Contains(err.Error(), "run manifest directory not found") {
|
||||
t.Fatalf("error = %v, want missing run-manifest directory error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDoesNotWriteCurrentPointerWhenOutputUploadFails(t *testing.T) {
|
||||
func TestPublishFailurePreservesPreviousCommittedSnapshotAndRetryIsDeterministic(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
trimmedKey := m.S3SessionPrefix + "transcripts/final.trimmed.json"
|
||||
oldRunID := "20260515T010203Z-a1b2c3d4"
|
||||
seedCommittedCurrentState(t, fake, m, oldRunID)
|
||||
pointerKey := artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix)
|
||||
priorPointer := append([]byte(nil), fake.Objects[pointerKey].Data...)
|
||||
failingKey := publishOutputRemoteKey(m.S3RunPrefix, "transcripts/final.trimmed.json")
|
||||
fake.UploadHook = func(call storage.FakeUploadCall) error {
|
||||
if call.Key == failingKey {
|
||||
return errors.New("forced upload failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
origUploadErr := fake.UploadErr
|
||||
fake.UploadErr = nil
|
||||
failingKey := trimmedKey
|
||||
_, err := publishStage{}.Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "forced upload failure") {
|
||||
t.Fatalf("Run() error = %v, want immutable object upload failure", err)
|
||||
}
|
||||
if got := fake.Objects[pointerKey].Data; !reflect.DeepEqual(got, priorPointer) {
|
||||
t.Fatalf("current pointer changed after failed publication: %q", got)
|
||||
}
|
||||
state, err := artifacts.LoadCurrentState(context.Background(), fake, m.S3SessionPrefix, artifacts.CurrentStateValidation{ValidateRunID: true})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCurrentState() error = %v", err)
|
||||
}
|
||||
if state.RunID != oldRunID {
|
||||
t.Fatalf("readable current run = %q, want %q", state.RunID, oldRunID)
|
||||
}
|
||||
for _, upload := range fake.Uploads {
|
||||
if upload.Key == pointerKey {
|
||||
t.Fatalf("current pointer was uploaded after a pre-commit failure")
|
||||
}
|
||||
}
|
||||
|
||||
fake.UploadHook = nil
|
||||
fake.Uploads = nil
|
||||
|
||||
originalUpload := fake.Upload
|
||||
_ = originalUpload
|
||||
// Use UploadErr toggle by checking call sequence in postcondition.
|
||||
// First failure point is published output upload; simulate by setting error immediately before published output key write.
|
||||
// We cannot hook FakeBackend per-key without changing public behavior; use dedicated backend wrapper instead.
|
||||
env.ObjectStore = &publishedOutputFailingStore{delegate: fake, failKey: failingKey}
|
||||
|
||||
_, err := publishStage{}.Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "upload published output source") {
|
||||
t.Fatalf("Run() error = %v, want published output upload failure", err)
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("retry Run() error = %v", err)
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; ok {
|
||||
t.Fatalf("unexpected current pointer write on published output failure")
|
||||
if len(fake.Uploads) == 0 || fake.Uploads[len(fake.Uploads)-1].Key != pointerKey {
|
||||
t.Fatalf("retry uploads = %#v, want pointer as final mutation", fake.Uploads)
|
||||
}
|
||||
state, err = artifacts.LoadCurrentState(context.Background(), fake, m.S3SessionPrefix, artifacts.CurrentStateValidation{ValidateRunID: true})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCurrentState() after retry error = %v", err)
|
||||
}
|
||||
if state.RunID != m.RunID {
|
||||
t.Fatalf("retried current run = %q, want %q", state.RunID, m.RunID)
|
||||
}
|
||||
if state.Commit == nil {
|
||||
t.Fatal("retried current state has no immutable commit")
|
||||
}
|
||||
destinations := make([]string, 0, len(state.Commit.Artifacts))
|
||||
for _, artifact := range state.Commit.Artifacts {
|
||||
destinations = append(destinations, artifact.DestinationKey)
|
||||
}
|
||||
if !sort.StringsAreSorted(destinations) {
|
||||
t.Fatalf("retried immutable mapping is not deterministic: %v", destinations)
|
||||
}
|
||||
if _, exists := fake.Objects[m.S3SessionPrefix+"current/manifest.json"]; exists {
|
||||
t.Fatal("new publication wrote legacy current manifest")
|
||||
}
|
||||
fake.UploadErr = origUploadErr
|
||||
}
|
||||
|
||||
func TestPublishDoesNotWriteCurrentPointerWhenCurrentManifestUploadFails(t *testing.T) {
|
||||
func TestPublishRejectsConflictingImmutableObject(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
env.ObjectStore = &publishedOutputFailingStore{delegate: fake, failKey: m.S3SessionPrefix + "current/manifest.json"}
|
||||
conflictingKey := publishOutputRemoteKey(m.S3RunPrefix, "transcripts/final.trimmed.json")
|
||||
fake.SeedObject(storage.FakeObject{Key: conflictingKey, Data: []byte("different content\n")})
|
||||
|
||||
_, err := publishStage{}.Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "current manifest") {
|
||||
t.Fatalf("Run() error = %v, want current manifest upload failure", err)
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists with different content") {
|
||||
t.Fatalf("Run() error = %v, want immutable conflict", err)
|
||||
}
|
||||
if _, ok := fake.Objects[m.S3SessionPrefix+"current/run_id.txt"]; ok {
|
||||
t.Fatalf("unexpected current pointer write when current manifest upload fails")
|
||||
if _, exists := fake.Objects[artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix)]; exists {
|
||||
t.Fatal("current pointer was updated after immutable object conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outputs []config.PublishOutputRule
|
||||
}{
|
||||
{
|
||||
name: "ambiguous source",
|
||||
outputs: []config.PublishOutputRule{
|
||||
{Source: "narratio.transcript.final_trimmed", Dest: "published/first.json", Required: boolPtr(true)},
|
||||
{Source: "narratio.transcript.final_trimmed", Dest: "published/second.json", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "colliding destination",
|
||||
outputs: []config.PublishOutputRule{
|
||||
{Source: "narratio.transcript.final_trimmed", Dest: "published/shared.json", Required: boolPtr(true)},
|
||||
{Source: "narratio.artifact.session_recap", Dest: "published/shared.json", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
env.Config.Pipeline.Publish.Outputs = test.outputs
|
||||
_, err := (publishStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "immutable upload mapping") {
|
||||
t.Fatalf("Run() error = %v, want invalid mapping", err)
|
||||
}
|
||||
if uploads := env.ObjectStore.(*storage.FakeBackend).Uploads; len(uploads) != 0 {
|
||||
t.Fatalf("uploads = %#v, want mapping failure before upload", uploads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
||||
env, m, _ := publishFixture(t)
|
||||
writeStageTestFile(t, filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md"), "# other recap\n")
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
|
||||
}
|
||||
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
|
||||
{Source: "narratio.artifact.other_recap", Dest: "published/second/session_recap.md", Required: boolPtr(true)},
|
||||
}
|
||||
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
fake := env.ObjectStore.(*storage.FakeBackend)
|
||||
first := publishOutputRemoteKey(m.S3RunPrefix, "published/first/session_recap.md")
|
||||
second := publishOutputRemoteKey(m.S3RunPrefix, "published/second/session_recap.md")
|
||||
if string(fake.Objects[first].Data) != "# recap\n" || string(fake.Objects[second].Data) != "# other recap\n" {
|
||||
t.Fatalf("same-basename outputs were not kept distinct: first=%q second=%q", fake.Objects[first].Data, fake.Objects[second].Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,35 +1121,53 @@ func configurePublishExtractionFixture(t *testing.T, env *Env, m *manifest.Manif
|
||||
return lanePath
|
||||
}
|
||||
|
||||
type publishedOutputFailingStore struct {
|
||||
delegate *storage.FakeBackend
|
||||
failKey string
|
||||
}
|
||||
|
||||
func (s *publishedOutputFailingStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
return s.delegate.List(ctx, prefix)
|
||||
}
|
||||
|
||||
func (s *publishedOutputFailingStore) Download(ctx context.Context, key, localPath string) error {
|
||||
return s.delegate.Download(ctx, key, localPath)
|
||||
}
|
||||
|
||||
func (s *publishedOutputFailingStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||
if strings.TrimSpace(key) == strings.TrimSpace(s.failKey) {
|
||||
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||
func seedCommittedCurrentState(t *testing.T, fake *storage.FakeBackend, source *manifest.Manifest, runID string) {
|
||||
t.Helper()
|
||||
copy := *source
|
||||
copy.RunID = runID
|
||||
manifestData, err := json.Marshal(©)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal committed manifest: %v", err)
|
||||
}
|
||||
return s.delegate.Upload(ctx, localPath, key, opts)
|
||||
}
|
||||
|
||||
func (s *publishedOutputFailingStore) UploadReader(ctx context.Context, source io.Reader, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||
if strings.TrimSpace(key) == strings.TrimSpace(s.failKey) {
|
||||
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||
manifestData = append(manifestData, '\n')
|
||||
manifestKey := artifacts.S3RunSessionManifestKey(copy.S3SessionPrefix, runID)
|
||||
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: manifestData})
|
||||
manifestObject := fake.Objects[manifestKey]
|
||||
commit := artifacts.RemoteCommitManifest{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: copy.Campaign,
|
||||
SessionID: copy.SessionID,
|
||||
RunID: runID,
|
||||
Artifacts: []artifacts.RemoteArtifact{{
|
||||
Type: artifacts.RemoteArtifactTypeSessionManifest,
|
||||
Source: "session.manifest",
|
||||
DestinationKey: manifestKey,
|
||||
SHA256: publishSHA256(manifestData),
|
||||
Size: int64(len(manifestData)),
|
||||
Generation: manifestObject.ETag,
|
||||
}},
|
||||
}
|
||||
return s.delegate.UploadReader(ctx, source, key, opts)
|
||||
}
|
||||
|
||||
func (s *publishedOutputFailingStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||
return s.delegate.Exists(ctx, key)
|
||||
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
||||
if err != nil {
|
||||
t.Fatalf("encode remote commit: %v", err)
|
||||
}
|
||||
commitKey := artifacts.S3RunCommitKey(copy.S3SessionPrefix, runID)
|
||||
fake.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData})
|
||||
commitObject := fake.Objects[commitKey]
|
||||
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: copy.Campaign,
|
||||
SessionID: copy.SessionID,
|
||||
RunID: runID,
|
||||
CommitKey: commitKey,
|
||||
CommitSHA256: publishSHA256(commitData),
|
||||
CommitSize: int64(len(commitData)),
|
||||
CommitGeneration: commitObject.ETag,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode current commit pointer: %v", err)
|
||||
}
|
||||
fake.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(copy.S3SessionPrefix), Data: pointerData})
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
|
||||
Reference in New Issue
Block a user