Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion boatstack/cmd/boatstack-helper/delegation_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,38 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
err = settleErr
}
if err != nil || resolved.Prescription == nil {
return resolved, err
if err != nil {
return resolved, err
}
rebound, changed, rebindErr := bindContinuationCandidate(ctx, bound, resolved)
if rebindErr != nil {
return surfaces.Response{}, rebindErr
}
if !changed {
return resolved, nil
}
resolveRequest, err = buildRequest(surfaces.OperationResolve, rebound)
if err != nil {
return surfaces.Response{}, err
}
_, delegationResponse, err = prepareDelegation(ctx, &resolveRequest)
if err != nil {
return surfaces.Response{}, err
}
if delegationResponse != nil {
return *delegationResponse, nil
}
kernel, err = standardKernel(ctx, resolveRequest)
if err != nil {
return surfaces.Response{}, err
}
resolved, err = kernel.Handle(ctx, resolveRequest)
if settleErr := settleDelegationAtTarget(ctx, resolveRequest, resolved, kernel.TargetSatisfied(resolved.Snapshot, resolveRequest.Objective), false); settleErr != nil && err == nil {
err = settleErr
}
if err != nil || resolved.Prescription == nil {
return resolved, err
}
}
applyRequest := resolveRequest
applyRequest.Operation = surfaces.OperationApply
Expand Down Expand Up @@ -304,6 +335,26 @@ func executeContinuationStep(ctx context.Context, options commandOptions) (surfa
return applied, nil
}

func bindContinuationCandidate(ctx context.Context, bound commandOptions, response surfaces.Response) (commandOptions, bool, error) {
if bound.transitionID != "" || response.Prescription != nil || response.Decision == nil || response.Decision.Kind != supervisor.DecisionCandidate || response.Decision.Transition == nil || len(response.Decision.Candidates) != 1 {
return bound, false, nil
}
candidate := response.Decision.Transition.ID
if response.Decision.Candidates[0] != candidate {
return bound, false, nil
}
rebound := bound
rebound.transitionID = string(candidate)
rebound, err := bindFlowEntry(ctx, rebound)
if err != nil {
return commandOptions{}, false, err
}
if len(rebound.parameters) <= len(bound.parameters) {
return bound, false, nil
}
return rebound, true, nil
}

func advanceContinuation(options *commandOptions, response surfaces.Response) error {
if response.Receipt == nil {
return nil
Expand Down
83 changes: 83 additions & 0 deletions boatstack/cmd/boatstack-helper/flow_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"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/supervisor"
"github.com/operatorstack/boatstack/boatstack/internal/softwaredelivery/surfaces"
)

Expand Down Expand Up @@ -855,6 +856,88 @@ func TestFlowEntryBindsStableRunAndResumesManagedPlan(t *testing.T) {
}
}

func TestContinuationRebindsOnlyRepositoryResolvedCandidateParameters(t *testing.T) {
// control-law: continuation-may-re-resolve-only-one-supervisor-candidate-with-repository-owned-parameters
repository := flowRepository(t)
writeFixture(t, repository, ".boatstack/plans/inbox/delivery-one.md", []byte("exact plan"))
bound, err := bindFlowEntry(context.Background(), commandOptions{
repository: repository, programID: "product-delivery", entryID: "run", host: "codex",
})
if err != nil {
t.Fatal(err)
}
objectiveBind := catalog.Transition{ID: "objective.bind", Parameters: []catalog.ParameterSpec{{Name: "target_id", Required: true}, {Name: "delivery_id", Required: true}}}
rebound, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{
Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"},
}})
if err != nil || !changed || rebound.transitionID != "objective.bind" {
t.Fatalf("repository candidate rebind = options=%#v changed=%t err=%v", rebound, changed, err)
}
parameters, err := parseParameters(rebound.parameters)
if err != nil {
t.Fatal(err)
}
if target, ok := parameters.Get("target_id"); !ok || target != "published-pr" {
t.Fatalf("bound target = %q, %t", target, ok)
}
if delivery, ok := parameters.Get("delivery_id"); !ok || delivery != "delivery-one" {
t.Fatalf("bound delivery = %q, %t", delivery, ok)
}
planCreate := catalog.Transition{ID: "plan.create", Parameters: []catalog.ParameterSpec{{Name: "source_path", Required: true}, {Name: "source_fingerprint", Required: true}, {Name: "delivery_id", Required: true}}}
planBound, planChanged, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &supervisor.Decision{
Kind: supervisor.DecisionCandidate, Transition: &planCreate, Candidates: []catalog.TransitionID{"plan.create"},
}})
if err != nil || !planChanged || planBound.transitionID != "plan.create" {
t.Fatalf("plan candidate rebind = options=%#v changed=%t err=%v", planBound, planChanged, err)
}
planParameters, err := parseParameters(planBound.parameters)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"source_path", "source_fingerprint", "delivery_id"} {
if _, ok := planParameters.Get(name); !ok {
t.Fatalf("plan parameter %q was not bound", name)
}
}

for name, decision := range map[string]supervisor.Decision{
"ambiguous": {
Kind: supervisor.DecisionCandidate, Transition: &objectiveBind,
Candidates: []catalog.TransitionID{"objective.bind", "plan.create"},
},
"mismatched": {
Kind: supervisor.DecisionCandidate, Transition: &objectiveBind,
Candidates: []catalog.TransitionID{"plan.create"},
},
"human-question": {
Kind: supervisor.DecisionCandidate,
Transition: &catalog.Transition{ID: "plan.approve", Parameters: []catalog.ParameterSpec{{Name: "plan_fingerprint", Required: true}}, Prescription: catalog.Prescription{AuthorityPrompt: "Approve exact plan bytes"}},
Candidates: []catalog.TransitionID{"plan.approve"},
},
} {
t.Run(name, func(t *testing.T) {
result, reboundChanged, reboundErr := bindContinuationCandidate(context.Background(), bound, surfaces.Response{Decision: &decision})
if reboundErr != nil || reboundChanged || result.transitionID != "" || len(result.parameters) != 0 {
t.Fatalf("unsafe candidate rebound = options=%#v changed=%t err=%v", result, reboundChanged, reboundErr)
}
})
}

explicit := bound
explicit.transitionID = "objective.bind"
if _, changed, err := bindContinuationCandidate(context.Background(), explicit, surfaces.Response{Decision: &supervisor.Decision{
Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"},
}}); err != nil || changed {
t.Fatalf("explicit transition rebound changed=%t err=%v", changed, err)
}
if _, changed, err := bindContinuationCandidate(context.Background(), bound, surfaces.Response{
Decision: &supervisor.Decision{Kind: supervisor.DecisionCandidate, Transition: &objectiveBind, Candidates: []catalog.TransitionID{"objective.bind"}},
Prescription: &protocol.Prescription{TransitionID: "objective.bind"},
}); err != nil || changed {
t.Fatalf("prescribed response rebound changed=%t err=%v", changed, err)
}
}

func TestRepositoryNamedAbandonmentEntryUsesCompiledObjective(t *testing.T) {
entry := controlprogram.Entry{ID: "cancel", Target: "safely-abandoned"}
plan, delivery, err := resolveBoundPlan(t.TempDir(), entry, softwareflow.EntryObjective{
Expand Down
3 changes: 3 additions & 0 deletions release-notes/2026-08-13-flow-continuation-parameters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Continue repository-resolved Flow steps

Repository Flow runs now bind declared repository inputs when the supervisor selects a single parameterized transition. Human questions and ambiguous candidates remain suspended.
Loading