Migrate lock commands to independent objects

This commit is contained in:
2026-09-05 13:11:11 +00:00
parent 4bc33773fa
commit 610eaefb27
13 changed files with 368 additions and 156 deletions

View File

@@ -515,7 +515,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) {
}
}
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
@@ -534,13 +534,17 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
if code != 0 {
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
key, err := artifacts.RemoteLockKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "narratio.transcript.final_trimmed")
if err != nil {
t.Fatal(err)
}
obj, ok := fake.Objects[key]
if !ok {
t.Fatalf("remote locks key %q not uploaded", key)
}
if !strings.Contains(string(obj.Data), "source: narratio.transcript.final_trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") {
t.Fatalf("lock store data = %q", string(obj.Data))
lock, err := artifacts.DecodeRemoteLock(obj.Data)
if err != nil || lock.Source != "narratio.transcript.final_trimmed" || lock.Reason != "manual edit" {
t.Fatalf("lock object = %#v, %v", lock, err)
}
stdout.Reset()
@@ -554,7 +558,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
if code != 0 {
t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "- narratio.transcript.final_trimmed origin=remote") {
if !strings.Contains(stdout.String(), "- narratio.transcript.final_trimmed published reason=manual edit created_at=") {
t.Fatalf("stdout = %q, want remote lock", stdout.String())
}
@@ -569,15 +573,56 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
if code != 0 {
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
}
store, err := config.LoadPublishLockStoreBytes("locks.yml", fake.Objects[key].Data, nil, nil)
if _, exists := fake.Objects[key]; exists {
t.Fatalf("lock object %q remains after remove", key)
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 || !strings.Contains(stdout.String(), "was not locked") {
t.Fatalf("idempotent remove exit code = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if storeInitCalls != 4 {
t.Fatalf("object store init calls = %d, want 4", storeInitCalls)
}
}
func TestExecuteLocksListIsSortedAndRejectsMalformedObjects(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "second")
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final", "first")
args := []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := Execute(args, &stdout, &stderr); code != 0 {
t.Fatalf("locks list exit code = %d; stderr=%q", code, stderr.String())
}
first := strings.Index(stdout.String(), "narratio.transcript.final ")
second := strings.Index(stdout.String(), "narratio.transcript.final_trimmed ")
if first < 0 || second < 0 || first >= second {
t.Fatalf("locks list is not source sorted: %q", stdout.String())
}
badKey, err := artifacts.RemoteLockKey(sessionPrefix, "narratio.transcript.base")
if err != nil {
t.Fatalf("LoadPublishLockStoreBytes() error = %v", err)
t.Fatal(err)
}
if len(store.Locks) != 0 {
t.Fatalf("locks after remove = %#v, want empty", store.Locks)
}
if storeInitCalls != 3 {
t.Fatalf("object store init calls = %d, want 3", storeInitCalls)
fake.SeedObject(storage.FakeObject{Key: badKey, Data: []byte(`{"format_version":1}`)})
stdout.Reset()
stderr.Reset()
if code := Execute(args, &stdout, &stderr); code == 0 {
t.Fatalf("malformed lock list exit code = 0; stdout=%q", stdout.String())
}
}
@@ -747,9 +792,47 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
if code != 0 {
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
if !strings.Contains(string(fake.Objects[key].Data), "reason: second") {
t.Fatalf("lock store data = %q, want updated reason", string(fake.Objects[key].Data))
key, err := artifacts.RemoteLockKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "narratio.transcript.final_trimmed")
if err != nil {
t.Fatal(err)
}
lock, err := artifacts.DecodeRemoteLock(fake.Objects[key].Data)
if err != nil || lock.Reason != "second" {
t.Fatalf("lock object = %#v, %v; want updated reason", lock, err)
}
}
func TestExecuteLocksForceReportsConcurrentReplacement(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
key := seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "first")
replacement, err := artifacts.NewRemoteLock("narratio.transcript.final_trimmed", "concurrent", time.Date(2026, 5, 20, 1, 2, 3, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
replacementData, err := artifacts.EncodeRemoteLock(replacement)
if err != nil {
t.Fatal(err)
}
fake.UploadHook = func(call storage.FakeUploadCall) error {
if call.Key == key {
fake.SeedObject(storage.FakeObject{Key: key, Data: replacementData})
}
return nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--force",
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
}, &stdout, &stderr)
if code == 0 || !strings.Contains(stderr.String(), "changed concurrently; retry") {
t.Fatalf("forced conflict exit code = %d, stderr=%q", code, stderr.String())
}
}
@@ -825,7 +908,7 @@ func TestExecuteLocksMutationRejectsSessionIDMismatch(t *testing.T) {
}
}
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
func TestExecuteLocksDoNotTreatStaticConfigAsAuthority(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addStaticPublishLockToPipelineConfig(t, pipelinePath, "narratio.transcript.final_trimmed")
@@ -841,11 +924,8 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code == 0 {
t.Fatal("locks add static lock exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "locked by pipeline config") {
t.Fatalf("stderr = %q, want static lock error", stderr.String())
if code != 0 {
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
}
stdout.Reset()
@@ -856,11 +936,8 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code == 0 {
t.Fatal("locks remove static lock exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "locked by pipeline config") {
t.Fatalf("stderr = %q, want static lock error", stderr.String())
if code != 0 {
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
}
}
@@ -1125,12 +1202,11 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
`)
fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
seedOperatorCommittedOutputs(t, fake, map[string]string{
"narratio.transcript.final_trimmed": "transcripts/final.trimmed.json",
"narratio.transcript.final": "transcripts/full.json",
})
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "remote review")
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
@@ -1484,6 +1560,24 @@ func replaceInFileOrFatal(t *testing.T, path, old, new string) {
}
}
func seedOperatorRemoteLock(t *testing.T, fake *storage.FakeBackend, sessionPrefix, source, reason string) string {
t.Helper()
lock, err := artifacts.NewRemoteLock(source, reason, time.Date(2026, 5, 19, 1, 2, 3, 0, time.UTC))
if err != nil {
t.Fatalf("NewRemoteLock() error = %v", err)
}
data, err := artifacts.EncodeRemoteLock(lock)
if err != nil {
t.Fatalf("EncodeRemoteLock() error = %v", err)
}
key, err := artifacts.RemoteLockKey(sessionPrefix, source)
if err != nil {
t.Fatalf("RemoteLockKey() error = %v", err)
}
fake.SeedObject(storage.FakeObject{Key: key, Data: data})
return key
}
func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string, string, string) {
t.Helper()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)