diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md new file mode 100644 index 000000000..9315f3767 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-frontier-dashboard.md @@ -0,0 +1,5 @@ +### One command now shows every feature, its position, and whose move it is + +`flow frontier` renders a read-only dashboard across all of your managed deliveries: each row names the feature, its observed position (building, awaiting review, PR checks failing, eligible to merge, complete), and the actor who owes the next step — you or the agent — with the exact prescribed command when one exists. Earlier slices that are published with a still-open pull request appear as their own rows, so a red check on an already-published slice is visible while a later slice builds. + +The dashboard is a pure report: it performs no writes at all, one unverifiable delivery becomes one blocked row instead of hiding your healthy work, and a row's owner always matches what `flow next` would say for the same feature. Before this, reconstructing "where is everything and what is waiting on me" required running status per feature and reading each answer. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go index a8464483f..7b5874cbf 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go @@ -14,7 +14,7 @@ import ( // gate, authority, or exit code. func flowCommand(arguments []string) int { if len(arguments) == 0 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") return 2 } switch arguments[0] { @@ -24,6 +24,8 @@ func flowCommand(arguments []string) int { return flowNextCommand(arguments[1:]) case "tasks": return flowTasksCommand(arguments[1:]) + case "frontier": + return flowFrontierCommand(arguments[1:]) case "report": return flowReportCommand(arguments[1:]) default: @@ -147,6 +149,34 @@ func executePrescribed(cmd *boatstack.PrescribedCommand) error { } } +// flowFrontierCommand renders the cross-delivery frontier dashboard: one row +// per managed delivery slice with its observed position and the actor who owes +// the next step. Strictly read-only — it performs zero writes, including the +// terminal PR-state cache that next/recovery maintain. +// control-law: frontier-reports-never-mutates +func flowFrontierCommand(arguments []string) int { + flags := flag.NewFlagSet("flow frontier", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose delivery frontier should be reported") + jsonOutput := flags.Bool("json", false, "print the structured frontier report") + if err := flags.Parse(arguments); err != nil { + return 2 + } + frontier, err := boatstack.ResolveFrontier(*repo) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(frontier) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowFrontier(frontier)) + } + return 0 +} + // flowTasksCommand renders the active delivery slice's sub-actions from the // compiled plan task DAG, in dependency order, with the one to start pointed at. // It is read-only and never fails on flow position — an unresolved slice or an diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go new file mode 100644 index 000000000..6acbe7c30 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier.go @@ -0,0 +1,223 @@ +package boatstack + +import ( + "fmt" + "strings" +) + +// The frontier report answers "where is every ball, and whose is it" in one +// read-only view: every managed delivery — active, published, or invalid — +// becomes a row carrying its observed position and the actor who owes the +// next step. It is pure presentation over the same resolution the flow oracle +// uses, so a frontier row can never disagree with `flow next` for the same +// delivery; and unlike next/recovery it performs ZERO writes — not even the +// best-effort terminal PR-state cache — because a report that mutates is a +// report that can lie about what it found. +// control-law: frontier-reports-never-mutates +const flowFrontierSchemaVersion = 1 + +// FrontierRow is one delivery slice's position on the operator frontier. +type FrontierRow struct { + Feature string `json:"feature"` + Slice string `json:"slice,omitempty"` + SliceIndex int `json:"slice_index,omitempty"` + TotalSlices int `json:"total_slices,omitempty"` + Stage string `json:"stage"` + Lifecycle string `json:"lifecycle,omitempty"` + PRPhase string `json:"pr_phase,omitempty"` + PRFailingChecks []string `json:"pr_failing_checks,omitempty"` + PRURL string `json:"pr_url,omitempty"` + Actor string `json:"next_actor"` + NextOperation string `json:"next_operation"` + Prescribed string `json:"prescribed,omitempty"` + Reason string `json:"reason"` + Blocked bool `json:"blocked,omitempty"` +} + +// FlowFrontier is the full cross-delivery dashboard. +type FlowFrontier struct { + SchemaVersion int `json:"schema_version"` + Initialized bool `json:"initialized"` + Rows []FrontierRow `json:"rows"` + AgentSteps int `json:"agent_steps"` + OperatorSteps int `json:"operator_steps"` + TerminalRows int `json:"terminal_rows"` + BlockedRows int `json:"blocked_rows"` +} + +// ResolveFrontier builds the frontier report. Faults are partitioned, never +// propagated: one invalid delivery becomes one blocked row instead of +// poisoning the view of every healthy delivery (the same partition law the +// read-only recovery boundary uses). +// control-law: frontier-reports-never-mutates +// control-law: stale-delivery-cannot-block-unrelated-feature +func ResolveFrontier(repoPath string) (FlowFrontier, error) { + frontier := FlowFrontier{SchemaVersion: flowFrontierSchemaVersion} + repo, err := ResolveRepository(repoPath) + if err != nil { + return frontier, err + } + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { + return frontier, nil + } + frontier.Initialized = true + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + if configErr != nil { + return frontier, fmt.Errorf("boatstack project configuration is invalid; fix the config file (doctor diagnoses): %w", configErr) + } + states, invalid, err := allManagedDeliveryStates(repo) + if err != nil { + return frontier, err + } + states = withoutIgnoredDeliveryStates(states, config.Workflow.IgnoredDeliveries) + invalid = withoutIgnoredDeliveries(invalid, config.Workflow.IgnoredDeliveries) + + for _, slug := range invalid { + frontier.Rows = append(frontier.Rows, FrontierRow{ + Feature: slug, Stage: "INVALID_STATE", Actor: string(NextActorOperator), + NextOperation: "discard-delivery", Blocked: true, + Reason: "This managed delivery state cannot be verified; restore its evidence, ignore it, or discard it.", + }) + } + for _, state := range states { + if state.ActiveIndex < len(state.Slices) { + frontier.Rows = append(frontier.Rows, activeDeliveryRows(repo, state)...) + continue + } + branch, _, prURL := deliveryBranchAndSlice(state) + status := publishedNextStatus(state, observePRTarget(repo, prURL, branch)) + frontier.Rows = append(frontier.Rows, frontierRowFromStatus(repo, status)) + } + for _, row := range frontier.Rows { + switch { + case row.Blocked: + frontier.BlockedRows++ + case row.Actor == string(NextActorAgent): + frontier.AgentSteps++ + case row.Actor == string(NextActorNone): + frontier.TerminalRows++ + default: + frontier.OperatorSteps++ + } + } + return frontier, nil +} + +// activeDeliveryRows renders an active delivery: one row for the active slice +// via the authoritative resolution, plus one row for every earlier slice that +// is published with a still-open PR — those are live balls too (their checks +// can be failing while the active slice builds), and they are exactly the +// addressable set the actuators can still re-gate in place. +func activeDeliveryRows(repo string, state DeliveryState) []FrontierRow { + rows := []FrontierRow{} + status, err := nextForDelivery(repo, state.Feature) + if err != nil { + rows = append(rows, FrontierRow{ + Feature: state.Feature, Stage: "INVALID_STATE", Actor: string(NextActorOperator), + NextOperation: "discard-delivery", Blocked: true, + Reason: "The active managed delivery cannot be verified: " + err.Error(), + }) + } else { + rows = append(rows, frontierRowFromStatus(repo, status)) + } + limit := state.ActiveIndex + if limit > len(state.Slices) { + limit = len(state.Slices) + } + for i := 0; i < limit; i++ { + slice := state.Slices[i] + if slice.Status != "PUBLISHED" || strings.TrimSpace(slice.PRState) == "" || isTerminalPRState(slice.PRState) { + continue + } + observation := observePRTarget(repo, slice.PRURL, slice.HeadBranch) + sliceStatus := NextStatus{ + SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", + Feature: state.Feature, ActiveSlice: slice.ID, SliceIndex: i + 1, + TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none", + Lifecycle: observation.Lifecycle, PRURL: observation.URL, HeadBranch: observation.Branch, + PRPhase: string(observation.Phase), PRReviewDecision: observation.ReviewDecision, + PRMergeState: observation.MergeState, PRFailingChecks: observation.FailingChecks, + Reason: fmt.Sprintf("Slice %q is published with an open pull request while a later slice is active.", slice.ID), + } + rows = append(rows, frontierRowFromStatus(repo, sliceStatus)) + } + return rows +} + +// frontierRowFromStatus projects one resolved status through the SAME actor +// classification and prescription layer `flow next` uses — one resolution +// path, so the dashboard and the advisor can never name different owners for +// the same step. +func frontierRowFromStatus(repo string, status NextStatus) FrontierRow { + row := FrontierRow{ + Feature: status.Feature, Slice: status.ActiveSlice, + SliceIndex: status.SliceIndex, TotalSlices: status.TotalSlices, + Stage: status.ObservedStage, Lifecycle: status.Lifecycle, + PRPhase: status.PRPhase, PRFailingChecks: status.PRFailingChecks, + PRURL: status.PRURL, NextOperation: status.NextOperation, + Reason: status.Reason, + Blocked: status.VerificationStatus == "BLOCKED", + } + next, err := nextControlFromStatus(repo, status) + if err != nil { + row.Actor = string(NextActorOperator) + row.Blocked = true + return row + } + row.Actor = string(next.Actor) + if next.Prescribed != nil { + row.Prescribed = next.Prescribed.CommandLine() + } + return row +} + +// frontierPosition is the one-word position column: the observed PR phase when +// it is positively known, the stage otherwise. +func frontierPosition(row FrontierRow) string { + if row.PRPhase != "" && row.PRPhase != string(PRPhaseUnknown) { + return row.PRPhase + } + return row.Stage +} + +// FormatFlowFrontier renders the dashboard as fixed-width human-facing lines. +func FormatFlowFrontier(frontier FlowFrontier) string { + var b strings.Builder + if !frontier.Initialized { + b.WriteString("Boatstack is not tracking anything here yet.\n") + return b.String() + } + if len(frontier.Rows) == 0 { + b.WriteString("Frontier: no managed deliveries.\n") + return b.String() + } + fmt.Fprintf(&b, "Frontier: %d for you, %d for the agent, %d complete, %d blocked\n", + frontier.OperatorSteps, frontier.AgentSteps, frontier.TerminalRows, frontier.BlockedRows) + nameWidth, positionWidth := len("FEATURE"), len("POSITION") + for _, row := range frontier.Rows { + if len(frontierLabel(row)) > nameWidth { + nameWidth = len(frontierLabel(row)) + } + if len(frontierPosition(row)) > positionWidth { + positionWidth = len(frontierPosition(row)) + } + } + fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, "FEATURE", positionWidth, "POSITION", "ACTOR", "NEXT") + for _, row := range frontier.Rows { + next := row.NextOperation + if len(row.PRFailingChecks) > 0 { + next += " (failing: " + strings.Join(row.PRFailingChecks, ", ") + ")" + } + fmt.Fprintf(&b, "%-*s %-*s %-8s %s\n", nameWidth, frontierLabel(row), positionWidth, frontierPosition(row), row.Actor, next) + } + return b.String() +} + +// frontierLabel names a row: the feature, with the slice id appended when the +// delivery has more than one slice so two rows of one delivery stay distinct. +func frontierLabel(row FrontierRow) string { + if row.TotalSlices > 1 && row.Slice != "" { + return row.Feature + "/" + row.Slice + } + return row.Feature +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go new file mode 100644 index 000000000..851a2165e --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_frontier_conformance_test.go @@ -0,0 +1,224 @@ +package boatstack + +// control-law: frontier-reports-never-mutates +// +// The frontier dashboard is a pure projection: it reports every managed +// delivery's position and owing actor while performing ZERO writes — not even +// the best-effort terminal PR-state cache the next/recovery resolvers +// maintain. A report that mutates is a report that can lie about what it +// found. Companion laws exercised here: +// stale-delivery-cannot-block-unrelated-feature (one corrupt delivery is one +// blocked row, never a poisoned view) and +// turn-ends-only-at-the-operator-frontier (a frontier row's actor equals the +// flow advisor's actor for the same delivery — one classification path). +// +// Test classes: positive (a multi-delivery store renders every slice with a +// typed actor and live PR position), negative (a corrupt delivery yields one +// blocked row while healthy rows survive), bypass (state files are +// byte-identical after a frontier run, even under a terminal MERGED +// observation), relation (frontier actor == flow next actor per delivery). + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func frontierStateBytes(t *testing.T, repo string, features ...string) map[string]string { + t.Helper() + snapshot := map[string]string{} + for _, feature := range features { + path, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + value, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + snapshot[feature] = string(value) + } + return snapshot +} + +// Positive + relation: every delivery renders with a typed actor, the +// published delivery carries its live PR phase, and each row's actor matches +// the flow advisor's actor for the same feature. +func TestFrontierRendersEveryDeliveryWithTypedActor(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "building", "BUILD", 0) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + rows := map[string]FrontierRow{} + for _, row := range frontier.Rows { + rows[row.Feature] = row + } + building, ok := rows["building"] + if !ok || building.Stage != "BUILD" || building.Actor != string(NextActorAgent) { + t.Fatalf("unexpected building row: %#v", building) + } + if building.Prescribed == "" { + t.Fatal("an agent-owned row must carry its prescribed command") + } + shipped, ok := rows["shipped"] + if !ok || shipped.Stage != "PUBLISHED" || shipped.PRPhase != string(PRPhaseChecksFailing) { + t.Fatalf("unexpected shipped row: %#v", shipped) + } + if shipped.Actor != string(NextActorOperator) { + t.Fatalf("published-open step belongs to the operator today: %#v", shipped) + } + if frontier.AgentSteps != 1 || frontier.OperatorSteps != 1 || frontier.BlockedRows != 0 { + t.Fatalf("unexpected summary: %#v", frontier) + } + + // Relation: the frontier's actor for each feature equals the advisor's. + for _, feature := range []string{"building", "shipped"} { + next, nextErr := NextControl(repo, feature) + if nextErr != nil { + t.Fatal(nextErr) + } + if string(next.Actor) != rows[feature].Actor { + t.Fatalf("frontier actor %q disagrees with flow next actor %q for %s", rows[feature].Actor, next.Actor, feature) + } + } + + rendered := FormatFlowFrontier(frontier) + if !strings.Contains(rendered, "PR_CHECKS_FAILING") || !strings.Contains(rendered, "failing: unit") { + t.Fatalf("rendered frontier hides the live PR position:\n%s", rendered) + } +} + +// Positive: an active delivery with an earlier published-but-open slice shows +// both balls — the building active slice and the open PR of the earlier slice. +func TestFrontierShowsEarlierPublishedOpenSlices(t *testing.T) { + repo := nextTestRepo(t) + directory := filepath.Join(repo, ".product-loop", "features", "layered") + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + hash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "layered", PlanLockHash: hash, + ActiveIndex: 1, Slices: []DeliverySlice{ + {ID: "first", Title: "First", Status: "PUBLISHED", PRURL: "https://example.invalid/pr/9", HeadBranch: "feat/phase", PRState: "OPEN"}, + {ID: "second", Title: "Second", Status: "BUILD"}, + }, + }); err != nil { + t.Fatal(err) + } + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)) + + frontier, frontierErr := ResolveFrontier(repo) + if frontierErr != nil { + t.Fatal(frontierErr) + } + if len(frontier.Rows) != 2 { + t.Fatalf("want active + earlier published rows, got %#v", frontier.Rows) + } + var earlier *FrontierRow + for i := range frontier.Rows { + if frontier.Rows[i].Slice == "first" { + earlier = &frontier.Rows[i] + } + } + if earlier == nil || earlier.PRPhase != string(PRPhaseChecksFailing) || earlier.Actor != string(NextActorOperator) { + t.Fatalf("earlier published-open slice not surfaced: %#v", frontier.Rows) + } +} + +// Negative: one corrupt delivery becomes one blocked row; the healthy +// delivery's row survives untouched. +func TestFrontierPartitionsCorruptDeliveries(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "healthy", "BUILD", 0) + writeNextDelivery(t, repo, "corrupt", "BUILD", 0) + statePath, err := deliveryStatePath(repo, "corrupt") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(statePath, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + if len(frontier.Rows) != 2 || frontier.BlockedRows != 1 || frontier.AgentSteps != 1 { + t.Fatalf("unexpected partition: %#v", frontier) + } + for _, row := range frontier.Rows { + if row.Feature == "corrupt" && (!row.Blocked || row.NextOperation != "discard-delivery") { + t.Fatalf("corrupt delivery not routed to its remedy: %#v", row) + } + if row.Feature == "healthy" && (row.Blocked || row.Actor != string(NextActorAgent)) { + t.Fatalf("healthy delivery poisoned by corrupt neighbor: %#v", row) + } + } +} + +// Bypass: the frontier performs zero writes — the delivery ledger is +// byte-identical after a run, even when the live observation is terminal +// (MERGED), which next/recovery WOULD cache. The report never mutates. +func TestFrontierWritesNothingEvenOnTerminalObservation(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "building", "BUILD", 0) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + + before := frontierStateBytes(t, repo, "building", "shipped") + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + after := frontierStateBytes(t, repo, "building", "shipped") + for feature, value := range before { + if after[feature] != value { + t.Fatalf("frontier mutated delivery state for %q", feature) + } + } + var shipped FrontierRow + for _, row := range frontier.Rows { + if row.Feature == "shipped" { + shipped = row + } + } + if shipped.Actor != string(NextActorNone) || shipped.Stage != "FEATURE_COMPLETE" { + t.Fatalf("terminal observation misclassified: %#v", shipped) + } +} + +// Failure-state: an uninitialized repository reports an empty, unblocked +// frontier rather than an error. +func TestFrontierOnUninitializedRepository(t *testing.T) { + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + frontier, err := ResolveFrontier(repo) + if err != nil { + t.Fatal(err) + } + if frontier.Initialized || len(frontier.Rows) != 0 { + t.Fatalf("unexpected frontier: %#v", frontier) + } + if rendered := FormatFlowFrontier(frontier); !strings.Contains(rendered, "not tracking") { + t.Fatalf("unexpected rendering: %s", rendered) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index 7c5430002..53a16c73f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -144,6 +144,14 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { func nextForPublished(repo string, state DeliveryState) NextStatus { pr := observePublishedPR(repo, state) persistObservedTerminalPRState(repo, state, pr) + return publishedNextStatus(state, pr) +} + +// publishedNextStatus is the pure mapping from one live PR observation to the +// published NextStatus. Split from nextForPublished so the frontier report can +// present the same projection without nextForPublished's best-effort terminal +// cache write. control-law: frontier-reports-never-mutates +func publishedNextStatus(state DeliveryState, pr publishedPRObservation) NextStatus { _, sliceID, _ := deliveryBranchAndSlice(state) status := NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go index 56519790c..f2bed7490 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go @@ -197,6 +197,15 @@ func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBran func observePublishedPR(repo string, state DeliveryState) publishedPRObservation { branch, _, prURL := deliveryBranchAndSlice(state) + return observePRTarget(repo, prURL, branch) +} + +// observePRTarget performs the single live, read-only PR observation for one +// explicit PR URL or head branch. Split from observePublishedPR so callers +// that must not write anything (the frontier report) and callers that need a +// non-active slice's PR (an earlier published-but-open slice) share the exact +// same observation. +func observePRTarget(repo, prURL, branch string) publishedPRObservation { observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch, Phase: PRPhaseUnknown} target := prURL if target == "" {