diff --git a/boatstack/cmd/boatstack-helper/flow_runtime.go b/boatstack/cmd/boatstack-helper/flow_runtime.go index f5bdeff..dab593e 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime.go @@ -14,7 +14,10 @@ import ( "github.com/operatorstack/boatstack/boatstack/controlprogram" softwareflow "github.com/operatorstack/boatstack/boatstack/flow/softwaredelivery" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/durable" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/effects" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/plant" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces" ) @@ -59,6 +62,7 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if options.flowProgramFingerprint != "" && options.flowProgramFingerprint != compiled.Fingerprint { return commandOptions{}, fmt.Errorf("FLOW_PROGRAM_DRIFT: run fingerprint does not match the current artifact") } + options.flowProgramFingerprint = compiled.Fingerprint objective, err := softwareflow.ObjectiveForEntry(ctx, compiled, resolver, options.entryID) if err != nil { return commandOptions{}, err @@ -67,27 +71,35 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, if !ok { return commandOptions{}, fmt.Errorf("FLOW_ENTRY_UNKNOWN: %s", options.entryID) } - plan, deliveryID, err := resolveBoundPlan(repository, entry, options) + options, err = bindActiveFlowContext(ctx, repository, options, objective) if err != nil { return commandOptions{}, err } - planRaw, err := os.ReadFile(plan) - if err != nil { - return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", err) - } - planDigest := sha256.Sum256(planRaw) - planFingerprint := hex.EncodeToString(planDigest[:]) - repositoryIdentity, err := flowRepositoryIdentity(repository) + plan, deliveryID, err := resolveBoundPlan(repository, entry, objective, options) if err != nil { return commandOptions{}, err } - runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint) - if options.runID != "" && options.runID != runID { - return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + planFingerprint := "" + if plan != "" { + planRaw, readErr := os.ReadFile(plan) + if readErr != nil { + return commandOptions{}, fmt.Errorf("FLOW_INPUT_REQUIRED: read selected plan: %w", readErr) + } + planDigest := sha256.Sum256(planRaw) + planFingerprint = hex.EncodeToString(planDigest[:]) + repositoryIdentity, identityErr := flowRepositoryIdentity(repository) + if identityErr != nil { + return commandOptions{}, identityErr + } + runID := flowRunID(repositoryIdentity, compiled.Fingerprint, options.entryID, deliveryID, planFingerprint) + if options.runID != "" && options.runID != runID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the selected plan and repository") + } + options.runID = runID + } else if options.runID == "" { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active abandonment has no committed run identity") } options.repository = repository - options.flowProgramFingerprint = compiled.Fingerprint - options.runID = runID if options.objectiveKind == "" { options.objectiveKind = string(objective) } @@ -137,6 +149,80 @@ func bindFlowEntry(ctx context.Context, options commandOptions) (commandOptions, return options, nil } +func bindActiveFlowContext(ctx context.Context, repository string, options commandOptions, entryObjective model.ObjectiveKind) (commandOptions, error) { + if options.runID != "" && entryObjective != model.ObjectiveAbandoned { + return options, nil + } + resolver, err := plant.NewResolver("") + if err != nil { + return commandOptions{}, err + } + host := options.host + if host == "" { + host = "cli" + } + invocation, err := resolver.ResolveInvocation(ctx, repository, host, "flow-entry-resume") + if err != nil { + common, commonErr := flowRepositoryIdentity(repository) + if commonErr == nil { + if _, stateErr := os.Stat(filepath.Join(common, "boatstack", "v2")); os.IsNotExist(stateErr) { + return options, nil + } + } + if _, stateErr := os.Stat(filepath.Join(repository, ".git", "boatstack")); stateErr != nil { + return options, nil + } + return commandOptions{}, err + } + layout, _, err := resolver.ResolveLayout(ctx, invocation) + if err != nil { + return commandOptions{}, err + } + raw, err := os.ReadFile(layout.StatePath) + if os.IsNotExist(err) { + return options, nil + } + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: read durable state: %w", err) + } + state, err := durable.DecodeState(raw) + if err != nil { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: decode durable state: %w", err) + } + active, ok := state.ActiveObjective() + if !ok { + return options, nil + } + prefix := "objective-" + options.programID + "-" + options.entryID + "-" + receipt, found, findErr := effects.FindLatestCommittedFlowForObjective(layout, invocation, active, state.Revision) + if findErr != nil { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: inspect committed flow receipts: %w", findErr) + } + if !found || !strings.HasPrefix(receipt.FlowID, "run-") { + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_INVALID: active objective has no committed run identity") + } + if active.Kind == entryObjective && strings.HasPrefix(active.ID, prefix) { + options.runID, options.deliveryID = receipt.FlowID, active.DeliveryID + options.objectiveID, options.objectiveKind = active.ID, string(active.Kind) + options.activeFlowBound = true + return options, nil + } + if entryObjective == model.ObjectiveAbandoned { + repositoryIdentity, identityErr := flowRepositoryIdentity(repository) + if identityErr != nil { + return commandOptions{}, identityErr + } + expectedRunID := flowRunID(repositoryIdentity, options.flowProgramFingerprint, options.entryID, active.DeliveryID, "active-run:"+receipt.FlowID) + if options.runID != "" && options.runID != expectedRunID { + return commandOptions{}, fmt.Errorf("FLOW_RUN_MISMATCH: run ID does not identify the active delivery") + } + options.runID = expectedRunID + options.deliveryID, options.activeFlowBound = active.DeliveryID, true + return options, nil + } + return commandOptions{}, fmt.Errorf("FLOW_ACTIVE_RUN_CONFLICT: delivery %q is active under objective %q; abandon it before selecting another inbox plan", active.DeliveryID, active.ID) +} + func validateResolvedParameter(parameters protocol.Parameters, name, expected string) error { if actual, exists := parameters.Get(name); exists && actual != expected { return fmt.Errorf("FLOW_INPUT_MISMATCH: parameter %s conflicts with the entry-resolved value", name) @@ -186,8 +272,11 @@ func bindRPCFlowEntry(ctx context.Context, request surfaces.Request) (surfaces.R return request, nil } -func resolveBoundPlan(repository string, entry controlprogram.Entry, options commandOptions) (string, string, error) { - if options.runID == "" { +func resolveBoundPlan(repository string, entry controlprogram.Entry, entryObjective model.ObjectiveKind, options commandOptions) (string, string, error) { + if options.activeFlowBound && entryObjective == model.ObjectiveAbandoned { + return "", options.deliveryID, nil + } + if options.runID == "" && options.deliveryID == "" { return resolvePlanInput(repository, entry) } if !flowSegment.MatchString(options.deliveryID) { diff --git a/boatstack/cmd/boatstack-helper/flow_runtime_test.go b/boatstack/cmd/boatstack-helper/flow_runtime_test.go index 7078927..fe7b67f 100644 --- a/boatstack/cmd/boatstack-helper/flow_runtime_test.go +++ b/boatstack/cmd/boatstack-helper/flow_runtime_test.go @@ -820,6 +820,19 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) { } } +func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) { + entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"} + plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, model.ObjectiveAbandoned, commandOptions{ + entryID: "cancel", activeFlowBound: true, deliveryID: "delivery-one", + }) + if err != nil { + t.Fatal(err) + } + if plan != "" || delivery != "delivery-one" { + t.Fatalf("repository-named abandonment resolved plan=%q delivery=%q", plan, delivery) + } +} + func TestFlowEntryRejectsSelectedPlanContentSubstitution(t *testing.T) { // control-law: one-flow-run-binds-the-exact-selected-plan-bytes repository := flowRepository(t) diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 713dc6b..f095f8f 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -45,6 +45,7 @@ type commandOptions struct { deliveryID string programID string flowProgramFingerprint string + activeFlowBound bool entryID string runID string transitionID string diff --git a/boatstack/flow/softwaredelivery/definition.go b/boatstack/flow/softwaredelivery/definition.go index f7279d5..6375e37 100644 --- a/boatstack/flow/softwaredelivery/definition.go +++ b/boatstack/flow/softwaredelivery/definition.go @@ -88,6 +88,12 @@ func (d Definition) RuntimeManifest(ctx context.Context) (delivery.ProgramRuntim if len(transition.ObjectiveKinds) == 0 { return delivery.ProgramRuntimeManifest{}, fmt.Errorf("transition %q supports none of the declared entry objectives", declaration.ID) } + if transition.ID == "plan.abandon" && objectives[model.ObjectiveAbandoned] { + // A repository Flow that explicitly exposes a safely-abandoned entry + // makes abandonment progress for that objective only. Human authority + // remains mandatory and other objectives cannot select this transition. + transition.SelectionClass = delivery.SelectionProgramProgress + } sort.Slice(transition.ObjectiveKinds, func(i, j int) bool { return transition.ObjectiveKinds[i] < transition.ObjectiveKinds[j] }) selected = append(selected, transition) } diff --git a/boatstack/flow/softwaredelivery/definition_test.go b/boatstack/flow/softwaredelivery/definition_test.go index 55a2eef..095a8c1 100644 --- a/boatstack/flow/softwaredelivery/definition_test.go +++ b/boatstack/flow/softwaredelivery/definition_test.go @@ -151,6 +151,60 @@ func TestRepositoryTransitionCannotWidenTrustedObjectiveKinds(t *testing.T) { } } +func TestAbandonmentEntryMakesTrustedAbandonmentObjectiveProgress(t *testing.T) { + truth := true + resolver, err := softwareflow.NewResolver(context.Background()) + if err != nil { + t.Fatal(err) + } + document := controlprogram.Document{ + SchemaVersion: controlprogram.SchemaVersion, + Program: controlprogram.Program{ID: "product-delivery", Version: "1"}, + Facets: []controlprogram.Facet{ + {ID: "publication", Kind: "string"}, {ID: "verification", Kind: "string"}, + {ID: "configuration", Kind: "string"}, {ID: "runtime", Kind: "string"}, + {ID: "delivery", Kind: "string"}, {ID: "workspace", Kind: "string"}, + }, + Operators: []controlprogram.Operator{ + {ID: "publication.observe", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/publication.observe", Version: "1"}}, + {ID: "plan.abandon", Binding: &controlprogram.OperatorBinding{Reference: "software-delivery/plan.abandon", Version: "1"}}, + }, + Transitions: []controlprogram.Transition{ + {ID: "publication.observe", Operator: "publication.observe", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 77}, + {ID: "plan.abandon", Operator: "plan.abandon", Guard: controlprogram.Predicate{True: &truth}, Target: controlprogram.Predicate{True: &truth}, Priority: 31}, + }, + Targets: []controlprogram.Target{ + {ID: "published-pr", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("verification", "current"), fact("configuration", "verified"), fact("runtime", "verified"), fact("publication", "open")}}}, + {ID: "safely-abandoned", Predicate: controlprogram.Predicate{All: []controlprogram.Predicate{fact("delivery", "discarded"), {Fact: &controlprogram.FactPredicate{Facet: "workspace", Statuses: []string{"known"}, Values: []string{"abandoned", "absent"}}}}}}, + }, + Entries: []controlprogram.Entry{{ID: "run", Target: "published-pr"}, {ID: "abandon", Target: "safely-abandoned"}}, + } + compiled, err := controlprogram.Compile(document, resolver) + if err != nil { + t.Fatal(err) + } + definition, err := softwareflow.NewDefinition(compiled, resolver) + if err != nil { + t.Fatal(err) + } + manifest, err := definition.RuntimeManifest(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, transition := range manifest.Transitions { + if transition.ID == "plan.abandon" { + if transition.SelectionClass != delivery.SelectionProgramProgress || len(transition.ObjectiveKinds) != 1 || transition.ObjectiveKinds[0] != delivery.ObjectiveAbandoned { + t.Fatalf("abandonment transition = %#v", transition) + } + if transition.Priority != 31 { + t.Fatalf("priority = %d, want 31", transition.Priority) + } + return + } + } + t.Fatal("trusted plan.abandon transition was not selected") +} + func TestCompiledBindingDriftFailsClosed(t *testing.T) { truth := true compiled, resolver := compiledFlow(t, controlprogram.Predicate{True: &truth}) diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 10f6627..4964695 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -48,6 +48,19 @@ func renderSkill(compiled controlprogram.Compiled, entry controlprogram.Entry, s description = "Run repository Flow entry " + entry.ID + " to target " + entry.Target + "." } description += " Use only when the user explicitly selects this repository Flow entry." + supersession := "" + if entry.Target == "published-pr" { + abandonmentSkill, ok := targetEntrySkill(compiled.Document.Program.ID, compiled.Document.Entries, "safely-abandoned") + if ok { + supersession = fmt.Sprintf(` +If the user requests different work, never retarget this run. When no objective +binding receipt exists, stop this unbound attempt and allow the inbox plan to be +replaced. Once the objective is bound, require explicit use of $%s for +the same delivery and wait for its abandonment receipt before selecting a new +plan and starting a new run. +`, abandonmentSkill) + } + } return []byte(fmt.Sprintf(`--- name: %s description: %q @@ -67,11 +80,21 @@ Apply only the exact immediately preceding prescription and its declared parameters. A question suspends this run: ask the user, submit only the typed answer evidence, and resume the same run ID. Nothing continues in the background while input is missing. Never synthesize authority. +%s Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, supersession)) +} + +func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { + for _, entry := range entries { + if entry.Target == target { + return flowSkillSlug(programID, entry.ID), true + } + } + return "", false } func title(value string) string { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index e4e265c..8dc5369 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -75,6 +75,32 @@ func TestGeneratedSkillDescriptionIsQuotedYAML(t *testing.T) { } } +func TestGeneratedRunSkillRequiresExplicitAbandonmentBeforeReplacement(t *testing.T) { + compiled := controlprogram.Compiled{Document: controlprogram.Document{ + Program: controlprogram.Program{ID: "product-delivery"}, + Entries: []controlprogram.Entry{ + {ID: "run", Target: "published-pr"}, + {ID: "cancel", Target: "safely-abandoned"}, + }, + }} + files, err := softwareflow.GenerateSkills(compiled, []string{"codex", "claude"}) + if err != nil { + t.Fatal(err) + } + if len(files) != 6 { + t.Fatalf("generated file count = %d, want 6", len(files)) + } + run := string(files[".agents/skills/product-delivery-run/SKILL.md"]) + for _, contract := range []string{"never retarget this run", "$product-delivery-cancel", "abandonment receipt", "starting a new run"} { + if !strings.Contains(run, contract) { + t.Fatalf("generated run skill lacks %q", contract) + } + } + if _, ok := files[".agents/skills/product-delivery-cancel/SKILL.md"]; !ok { + t.Fatal("abandonment entry skill was not generated") + } +} + func TestGeneratedSkillsRejectKernelMaintenanceIdentity(t *testing.T) { compiled := controlprogram.Compiled{Document: controlprogram.Document{ Program: controlprogram.Program{ID: "boatstack"}, diff --git a/boatstack/flow/standard/supervisor_parity_test.go b/boatstack/flow/standard/supervisor_parity_test.go index f8d359c..d1fa1fd 100644 --- a/boatstack/flow/standard/supervisor_parity_test.go +++ b/boatstack/flow/standard/supervisor_parity_test.go @@ -114,10 +114,14 @@ func TestExplicitPostTerminalCleanupRemainsAdmissible(t *testing.T) { func TestTerminalEvidenceForOldObjectiveDoesNotTerminateNewObjective(t *testing.T) { // control-law: terminal-evidence-is-bound-to-exact-objective-not-local-phase s := New(testprogram.StandardRegistry(), testObjectiveContracts()) - newObjective := model.Objective{ID: "next-objective", Kind: model.ObjectiveOpenPR, DeliveryID: "delivery"} - decision := s.Resolve(snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished), newObjective, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "objective.bind") + snapshot := snapshotFor(t, model.PhaseTerminal, model.TerminalEstablished) + snapshot.Workspace = model.Known(model.WorkspacePublished, snapshot.Workspace.Evidence[0]) + snapshot.Publication = model.Known(model.PublicationOpen, snapshot.Publication.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + newObjective := model.Objective{ID: "next-objective", Kind: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} + decision := s.Resolve(snapshot, newObjective, catalog.AuthoritySet{catalog.AuthorityHuman: true}, "") if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "objective.bind" { - t.Fatalf("decision=%#v, want exact new-objective configuration", decision) + t.Fatalf("untargeted terminal replacement decision=%#v, want exact new-objective configuration", decision) } } diff --git a/boatstack/internal/softwaredelivery/durable/state.go b/boatstack/internal/softwaredelivery/durable/state.go index 590fd60..1780c28 100644 --- a/boatstack/internal/softwaredelivery/durable/state.go +++ b/boatstack/internal/softwaredelivery/durable/state.go @@ -163,6 +163,10 @@ func (s State) ConfigurationPolicy() model.ConfigurationPolicy { }.Canonical() } +func (s State) ActiveObjective() (model.Objective, bool) { + return s.Objective, s.Objective.ID != "" && s.Terminal == model.TerminalNonterminal +} + func EncodeState(state State) ([]byte, error) { state = state.Canonical() if err := state.Validate(); err != nil { diff --git a/boatstack/internal/softwaredelivery/effects/receipts.go b/boatstack/internal/softwaredelivery/effects/receipts.go index f0c1cc4..8468f73 100644 --- a/boatstack/internal/softwaredelivery/effects/receipts.go +++ b/boatstack/internal/softwaredelivery/effects/receipts.go @@ -72,7 +72,7 @@ func (s *ReceiptStore) layoutForFlow(_ context.Context, flowID string) (ports.Co return binding.layout, nil } -func scanCommittedReceipts(layout ports.ControllerLayout, visit func(protocol.TransitionReceipt) error) error { +func scanCommittedReceipts(layout ports.ControllerLayout, visit func(journalRecord) error) error { entries, err := os.ReadDir(layout.JournalRoot) if err != nil { if os.IsNotExist(err) { @@ -92,20 +92,50 @@ func scanCommittedReceipts(layout ports.ControllerLayout, visit func(protocol.Tr if record.Receipt == nil { return fmt.Errorf("committed transaction %s lacks a transition fact", entry.Name()) } - if err := visit(*record.Receipt); err != nil { + if err := visit(record); err != nil { return err } } return nil } +// FindLatestCommittedFlowForObjective returns the authoritative committed flow +// identity for the current objective. Projected receipt files are deliberately +// not used because projection is best effort. +func FindLatestCommittedFlowForObjective(layout ports.ControllerLayout, invocation model.InvocationContext, objective model.Objective, maximumRevision uint64) (protocol.TransitionReceipt, bool, error) { + var found protocol.TransitionReceipt + err := scanCommittedReceipts(layout, func(record journalRecord) error { + receipt := *record.Receipt + if !sameStateLineage(record.Admission.Invocation, invocation) { + return nil + } + if matchesObjectiveBinding(receipt, objective, maximumRevision) && (found.ID == "" || receipt.ResultingStateRevision > found.ResultingStateRevision) { + found = receipt + } + return nil + }) + return found, found.ID != "", err +} + +func sameStateLineage(left, right model.InvocationContext) bool { + return left.RepositoryID == right.RepositoryID && left.GitCommonID == right.GitCommonID && + left.WorktreeID == right.WorktreeID && left.ControllerID == right.ControllerID +} + +func matchesObjectiveBinding(receipt protocol.TransitionReceipt, objective model.Objective, maximumRevision uint64) bool { + return receipt.TransitionID == "objective.bind" && strings.HasPrefix(receipt.FlowID, "run-") && + receipt.ObjectiveID == objective.ID && receipt.ObjectiveKind == objective.Kind && receipt.DeliveryID == objective.DeliveryID && + receipt.ResultingStateRevision <= maximumRevision +} + func (s *ReceiptStore) NextSequence(ctx context.Context, flowID string) (uint64, error) { layout, err := s.layoutForFlow(ctx, flowID) if err != nil { return 0, err } var maximum uint64 - err = scanCommittedReceipts(layout, func(receipt protocol.TransitionReceipt) error { + err = scanCommittedReceipts(layout, func(record journalRecord) error { + receipt := *record.Receipt if receipt.FlowID == flowID && receipt.Sequence > maximum { maximum = receipt.Sequence } @@ -123,7 +153,8 @@ func (s *ReceiptStore) FindByIdempotency(ctx context.Context, invocation model.I return protocol.TransitionReceipt{}, false, err } var found protocol.TransitionReceipt - err = scanCommittedReceipts(layout, func(receipt protocol.TransitionReceipt) error { + err = scanCommittedReceipts(layout, func(record journalRecord) error { + receipt := *record.Receipt if receipt.IdempotencyKey == key { if found.ID != "" && found.ID != receipt.ID { return fmt.Errorf("idempotency key %q identifies multiple committed transition facts", key) diff --git a/boatstack/internal/softwaredelivery/effects/receipts_test.go b/boatstack/internal/softwaredelivery/effects/receipts_test.go new file mode 100644 index 0000000..4cc4dd0 --- /dev/null +++ b/boatstack/internal/softwaredelivery/effects/receipts_test.go @@ -0,0 +1,40 @@ +package effects + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/model" + "github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/protocol" +) + +func TestActiveFlowIdentityComesFromObjectiveBindingReceipt(t *testing.T) { + objective := model.Objective{ID: "objective-product-delivery-run-one", Kind: model.ObjectiveOpenPR, DeliveryID: "one"} + binding := protocol.TransitionReceipt{ID: "binding", FlowID: "run-original", TransitionID: "objective.bind", ObjectiveID: objective.ID, ObjectiveKind: objective.Kind, DeliveryID: objective.DeliveryID, ResultingStateRevision: 4} + maintenance := protocol.TransitionReceipt{ID: "maintenance", FlowID: "run-maintenance", TransitionID: "installation.reconcile-update", ObjectiveID: objective.ID, ObjectiveKind: objective.Kind, DeliveryID: objective.DeliveryID, ResultingStateRevision: 5} + if !matchesObjectiveBinding(binding, objective, 5) { + t.Fatal("objective binding receipt was not recognized") + } + if matchesObjectiveBinding(maintenance, objective, 5) { + t.Fatal("maintenance receipt replaced the active Flow identity") + } + if matchesObjectiveBinding(binding, objective, 3) { + t.Fatal("future objective binding receipt was accepted") + } +} + +func TestActiveFlowIdentityRequiresExactWorktreeLineage(t *testing.T) { + current := model.InvocationContext{RepositoryID: "repo", GitCommonID: "common", WorktreeID: "worktree-a", ControllerID: "controller"} + otherWorktree := current + otherWorktree.WorktreeID = "worktree-b" + otherController := current + otherController.ControllerID = "other-controller" + if !sameStateLineage(current, current) { + t.Fatal("exact worktree lineage was rejected") + } + if sameStateLineage(otherWorktree, current) { + t.Fatal("linked worktree receipt was accepted for the current durable state") + } + if sameStateLineage(otherController, current) { + t.Fatal("different controller lineage was accepted for the current durable state") + } +} diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer.go b/boatstack/internal/softwaredelivery/effects/state_reducer.go index 2cd6a09..0e7dae6 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer.go @@ -350,6 +350,13 @@ func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ ca if kind != string(admission.Objective.Kind) || delivery != admission.Objective.DeliveryID { return fmt.Errorf("objective parameters do not match admitted objective") } + if state.Objective.DeliveryID != "" && state.Objective.DeliveryID != admission.Objective.DeliveryID { + if state.Terminal != model.TerminalEstablished { + return fmt.Errorf("a different delivery requires the prior delivery to be terminal") + } + resetDeliveryState(state) + wasActive = false + } state.Objective = admission.Objective state.Terminal = model.TerminalNonterminal state.Phase = model.PhaseObserved @@ -361,6 +368,16 @@ func applyObjectiveBind(state *durable.State, admission protocol.Admission, _ ca return nil } +func resetDeliveryState(state *durable.State) { + state.Delivery, state.Plan = model.DeliveryUninitialized, model.PlanAbsent + state.Workspace = model.WorkspaceAbsent + state.Publication, state.Verification = model.PublicationNone, model.VerificationUnverified + state.PlanFingerprint, state.PublicationID, state.PublicationURL, state.PreviewFingerprint = "", "", "", "" + state.WorkspaceBranch, state.WorkspacePath, state.WorkspaceBaseRef = "", "", "" + state.WorkspaceSourcePath, state.WorkspaceSourceID, state.WorkspaceSourceRef = "", "", "" + state.Gates = nil +} + func applyPlanApprove(state *durable.State, admission protocol.Admission, _ catalog.Transition) error { state.Plan, state.Delivery, state.Phase = model.PlanApproved, model.DeliveryApproved, model.PhaseActive if admission.Objective.Kind == model.ObjectiveApprovedPlan { diff --git a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go index f14d603..991974a 100644 --- a/boatstack/internal/softwaredelivery/effects/state_reducer_test.go +++ b/boatstack/internal/softwaredelivery/effects/state_reducer_test.go @@ -133,6 +133,45 @@ func TestEscalatedRecoveryCanOnlyBeReconfiguredTowardExplicitAbandonment(t *test } } +func TestObjectiveBindStartsDifferentDeliveryFromCleanProductState(t *testing.T) { + prior := model.Objective{ID: "prior", Kind: model.ObjectiveAbandoned, DeliveryID: "prior-delivery"} + next := model.Objective{ID: "next", Kind: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} + state := durable.State{ + SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1, + Phase: model.PhaseTerminal, Engagement: model.EngagementCommand, Delivery: model.DeliveryTerminal, Workspace: model.WorkspacePublished, + Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationOpen, + Verification: model.VerificationCurrent, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalEstablished, + Objective: prior, PlanFingerprint: "old-plan", PublicationID: "old-pr", PublicationURL: "https://example.invalid/pr/1", + WorkspacePath: "/worktrees/prior", WorkspaceBranch: "feature/prior", WorkspaceSourcePath: "/source", WorkspaceSourceID: "source-id", WorkspaceSourceRef: "refs/heads/main", + PreviewFingerprint: "old-preview", Gates: []durable.GateEvidence{{Gate: "test", Revision: "old", Fingerprint: "old-test"}}, + } + transition, _ := testprogram.StandardRegistry().Lookup("objective.bind") + admission := protocol.Admission{Objective: next, Parameters: protocol.Parameters{{Name: "objective_kind", Value: string(next.Kind)}, {Name: "delivery_id", Value: next.DeliveryID}}} + if err := applyStateTransition(&state, admission, transition); err != nil { + t.Fatal(err) + } + if state.Objective != next || state.Delivery != model.DeliveryUninitialized || state.Workspace != model.WorkspaceAbsent || state.Plan != model.PlanAbsent || state.Publication != model.PublicationNone || state.Verification != model.VerificationUnverified || state.Terminal != model.TerminalNonterminal || len(state.Gates) != 0 || state.PlanFingerprint != "" || state.PublicationID != "" || state.WorkspacePath != "" { + t.Fatalf("new delivery inherited prior product state: %#v", state) + } +} + +func TestObjectiveBindRejectsDifferentDeliveryBeforeSafeTerminal(t *testing.T) { + prior := model.Objective{ID: "prior", Kind: model.ObjectiveOpenPR, DeliveryID: "prior-delivery"} + next := model.Objective{ID: "next", Kind: model.ObjectiveOpenPR, DeliveryID: "next-delivery"} + state := durable.State{ + SchemaVersion: durable.StateSchemaVersion, RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree", Revision: 1, + Phase: model.PhaseActive, Engagement: model.EngagementActive, Delivery: model.DeliveryActive, Workspace: model.WorkspaceActive, + Plan: model.PlanLocked, Configuration: model.ConfigurationVerified, Runtime: model.RuntimeVerified, Publication: model.PublicationNone, + Verification: model.VerificationUnverified, Recovery: model.RecoveryNone, Transaction: model.TransactionNone, Terminal: model.TerminalNonterminal, + Objective: prior, + } + transition, _ := testprogram.StandardRegistry().Lookup("objective.bind") + admission := protocol.Admission{Objective: next, Parameters: protocol.Parameters{{Name: "objective_kind", Value: string(next.Kind)}, {Name: "delivery_id", Value: next.DeliveryID}}} + if err := applyStateTransition(&state, admission, transition); err == nil || !strings.Contains(err.Error(), "prior delivery") { + t.Fatalf("different active delivery bind result = %v", err) + } +} + func TestDeclaredAssignmentReducesUnknownTransitionWithoutGoDispatch(t *testing.T) { state := durable.Default(model.InvocationContext{RepositoryID: "repo", GitCommonID: "git", WorktreeID: "worktree"}, testTime()) transition := catalog.Transition{ diff --git a/packages/boatstack-software-delivery/src/index.ts b/packages/boatstack-software-delivery/src/index.ts index 33e4ae9..19045b9 100644 --- a/packages/boatstack-software-delivery/src/index.ts +++ b/packages/boatstack-software-delivery/src/index.ts @@ -63,6 +63,15 @@ export const publishedPR: TargetDefinition = marked( "A provider-observed open or updated pull request", ); +export const safelyAbandoned: TargetDefinition = marked( + "safely-abandoned", + all( + fact("delivery", ["discarded"]), + fact("workspace", ["abandoned", "absent"]), + ), + "The selected delivery is explicitly and safely abandoned", +); + export interface TrustedStep { id: string; priority: number; @@ -95,6 +104,11 @@ export const runToPublishedPR: TrustedStep[] = [ { id: "publication.reconcile", priority: 1 }, ]; +export const runWithAbandonment: TrustedStep[] = [ + ...runToPublishedPR, + { id: "plan.abandon", priority: 31 }, +]; + export function inbox(path: string): EntryInputDefinition { return { id: "plan", @@ -149,7 +163,9 @@ export function productDeliveryFlow(input: { ], operators: trustedOperators(steps), transitions: trustedTransitions(steps), - targets: [publishedPR], + targets: input.entries.some((value) => value.target === safelyAbandoned.id) + ? [publishedPR, safelyAbandoned] + : [publishedPR], entries: input.entries, }; } @@ -162,3 +178,12 @@ export function runEntry(path = ".boatstack/plans/inbox"): EntryDefinition { "Implement one approved repository plan and publish a pull request", ); } + +export function abandonEntry(path = ".boatstack/plans/inbox"): EntryDefinition { + return entry( + "abandon", + "safely-abandoned", + [inbox(path)], + "Explicitly abandon the selected delivery before starting different work", + ); +} diff --git a/release-notes/2026-08-13-flow-run-abandonment.md b/release-notes/2026-08-13-flow-run-abandonment.md new file mode 100644 index 0000000..f33c8d9 --- /dev/null +++ b/release-notes/2026-08-13-flow-run-abandonment.md @@ -0,0 +1,3 @@ +### Replace a bound Flow run safely + +Repository Flows can now expose explicit abandonment before starting a replacement delivery. Inbox changes cannot retarget an active run.