@nanobpm/nano-workforce 0.129.1 → 0.131.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/app/agentic/cockpit/supply-boot.test.ts +49 -2
- package/app/agentic/cockpit/supply-boot.ts +44 -2
- package/app/agentic/cockpit/supply-render.test.ts +21 -0
- package/app/agentic/cockpit/supply-render.ts +19 -9
- package/app/agentic/cockpit/supply-view.test.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +9 -0
- package/app/delivery.ts +2 -2
- package/app/deliveryGraph.test.ts +279 -0
- package/app/deliveryGraph.ts +381 -2
- package/app/deliveryGraphCompiler.test.ts +119 -0
- package/app/deliveryGraphCompiler.ts +213 -44
- package/app/deliveryGraphDeploy.test.ts +148 -0
- package/app/deliveryUnitStatus.test.ts +143 -0
- package/app/deliveryUnitStatus.ts +242 -0
- package/app/plan.ts +3 -3
- package/docs/adr/0005-agent-authored-delivery-graphs.md +25 -0
- package/openapi.yaml +43 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +12 -0
- package/pages/cockpit/mount.js +47 -8
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Coverage for ADR 0006 slice S1 — the ONE canonical delivery-unit status union and the per-shape
|
|
2
|
+
// derivations that map each bespoke source union into it (app/deliveryUnitStatus.ts).
|
|
3
|
+
//
|
|
4
|
+
// Guards THREE things:
|
|
5
|
+
// 1. TOTALITY — every member of each source union (feature / plan aggregate / plan-task node /
|
|
6
|
+
// delivery-graph run) maps to a valid canonical member, and the compiled `fnFor` agrees with the
|
|
7
|
+
// declared map object (so the DSL derivation and the TS map cannot drift).
|
|
8
|
+
// 2. TERMINALITY PRECEDENCE — a source SETTLED/terminal status always maps to a canonical
|
|
9
|
+
// settled/terminal status, so a reconciler reading the union never mistakes a finished unit for a
|
|
10
|
+
// live one (or vice-versa).
|
|
11
|
+
// 3. FRAMEWORK PARITY — for each of the four read models, `assertReadModelParity` proves the SQL VIEW
|
|
12
|
+
// lowering and the TS `fnFor` lowering agree over the full source-status matrix.
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assertReadModelParity, type ParityDb, type ParitySample } from "@nanobpm/urban";
|
|
16
|
+
import { assert, assertEquals } from "#test-assert";
|
|
17
|
+
import { DELIVERY_GRAPH_RUN_STATUSES, DELIVERY_GRAPH_TERMINAL_STATUSES } from "./deliveryGraphRun.ts";
|
|
18
|
+
import {
|
|
19
|
+
DELIVERY_GRAPH_STATUS_TO_UNIT,
|
|
20
|
+
DELIVERY_STATUS_COLUMN,
|
|
21
|
+
DELIVERY_UNIT_SETTLED_STATUSES,
|
|
22
|
+
DELIVERY_UNIT_STATUSES,
|
|
23
|
+
DELIVERY_UNIT_TERMINAL_STATUSES,
|
|
24
|
+
type DeliveryUnitStatus,
|
|
25
|
+
deliveryGraphDeliveryStatus,
|
|
26
|
+
FEATURE_STATUS_TO_UNIT,
|
|
27
|
+
featureDeliveryStatus,
|
|
28
|
+
isDeliveryUnitSettled,
|
|
29
|
+
isDeliveryUnitTerminal,
|
|
30
|
+
PLAN_STATUS_TO_UNIT,
|
|
31
|
+
PLAN_STATUSES,
|
|
32
|
+
PLAN_TASK_STATUS_TO_UNIT,
|
|
33
|
+
planDeliveryStatus,
|
|
34
|
+
planTaskDeliveryStatus,
|
|
35
|
+
toDeliveryUnitStatus,
|
|
36
|
+
} from "./deliveryUnitStatus.ts";
|
|
37
|
+
import { FEATURE_RUN_STATUSES, FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
38
|
+
import { PLAN_TASK_STATUSES, PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
39
|
+
|
|
40
|
+
const CANONICAL = new Set<string>(DELIVERY_UNIT_STATUSES);
|
|
41
|
+
|
|
42
|
+
// ── Type-level No-Drift guard (issue #464 review) ────────────────────────────────────────────────
|
|
43
|
+
// `PLAN_STATUSES` is DERIVED from `EPIC_LIVE_STATUSES` (app/delivery.ts) + `PLAN_TERMINAL_STATUSES`
|
|
44
|
+
// (app/plan.ts). If either source is declared as a widened `readonly string[]` instead of an `as const`
|
|
45
|
+
// literal tuple, `(typeof PLAN_STATUSES)[number]` collapses to `string`, `PLAN_STATUS_TO_UNIT` degrades
|
|
46
|
+
// to `Record<string, …>`, and the exhaustiveness guard silently evaporates — `tsc` would no longer fail
|
|
47
|
+
// when the plan vocabulary gains a member without a canonical mapping. This assertion fails to COMPILE
|
|
48
|
+
// if that widening ever returns (the `false` branch makes `true` unassignable).
|
|
49
|
+
type _IsLiteralUnion<T extends string> = string extends T ? false : true;
|
|
50
|
+
const _planStatusesAreLiteral: _IsLiteralUnion<(typeof PLAN_STATUSES)[number]> = true;
|
|
51
|
+
void _planStatusesAreLiteral;
|
|
52
|
+
|
|
53
|
+
// A `ParityDb` over node:sqlite's `DatabaseSync` for `assertReadModelParity` (which needs positional
|
|
54
|
+
// exec/all/run, whereas `DatabaseSync` exposes query methods on prepared statements).
|
|
55
|
+
function parityDb(db: DatabaseSync): ParityDb {
|
|
56
|
+
return {
|
|
57
|
+
exec: (sql) => db.exec(sql),
|
|
58
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
59
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
60
|
+
run: (sql, params: unknown[] = []) => {
|
|
61
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
62
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const shapes = [
|
|
68
|
+
{ name: "feature", model: featureDeliveryStatus, sources: FEATURE_RUN_STATUSES, map: FEATURE_STATUS_TO_UNIT },
|
|
69
|
+
{ name: "plan", model: planDeliveryStatus, sources: PLAN_STATUSES, map: PLAN_STATUS_TO_UNIT },
|
|
70
|
+
{ name: "plan-task", model: planTaskDeliveryStatus, sources: PLAN_TASK_STATUSES, map: PLAN_TASK_STATUS_TO_UNIT },
|
|
71
|
+
{ name: "delivery-graph", model: deliveryGraphDeliveryStatus, sources: DELIVERY_GRAPH_RUN_STATUSES, map: DELIVERY_GRAPH_STATUS_TO_UNIT },
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
74
|
+
test("TOTALITY: every source status maps to a valid canonical member, and fnFor agrees with the declared map", () => {
|
|
75
|
+
for (const { name, model, sources, map } of shapes) {
|
|
76
|
+
for (const source of sources) {
|
|
77
|
+
const declared = (map as Record<string, DeliveryUnitStatus>)[source];
|
|
78
|
+
assert(declared !== undefined, `${name}: source status "${source}" has no canonical mapping`);
|
|
79
|
+
assert(CANONICAL.has(declared), `${name}: "${source}" maps to non-canonical "${declared}"`);
|
|
80
|
+
// The DSL-compiled TS lowering must produce the SAME canonical value as the declared map object.
|
|
81
|
+
assertEquals(toDeliveryUnitStatus(model, source), declared, `${name}: fnFor drift for "${source}"`);
|
|
82
|
+
}
|
|
83
|
+
// An out-of-band status is NULL (never an invented member), matching the VIEW's ELSE NULL.
|
|
84
|
+
assertEquals(toDeliveryUnitStatus(model, "not-a-real-status"), null, `${name}: unmapped status must be null`);
|
|
85
|
+
assertEquals(toDeliveryUnitStatus(model, null), null, `${name}: null status must be null`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("TERMINALITY PRECEDENCE: a source SETTLED/terminal status maps to a canonical settled/terminal status", () => {
|
|
90
|
+
// Feature counts opened/converging as SETTLED-for-redispatch (live PR stages), so check against the
|
|
91
|
+
// settled set; the plan aggregate and delivery-graph terminals are truly DONE, so check the done tier.
|
|
92
|
+
for (const s of FEATURE_TERMINAL_STATUSES) {
|
|
93
|
+
assert(isDeliveryUnitSettled(FEATURE_STATUS_TO_UNIT[s]), `feature terminal "${s}" must map to a settled canonical status`);
|
|
94
|
+
}
|
|
95
|
+
for (const s of PLAN_TERMINAL_STATUSES as readonly (keyof typeof PLAN_STATUS_TO_UNIT)[]) {
|
|
96
|
+
assert(isDeliveryUnitTerminal(PLAN_STATUS_TO_UNIT[s]), `plan terminal "${s}" must map to a done-tier canonical status`);
|
|
97
|
+
}
|
|
98
|
+
for (const s of DELIVERY_GRAPH_TERMINAL_STATUSES) {
|
|
99
|
+
assert(isDeliveryUnitTerminal(DELIVERY_GRAPH_STATUS_TO_UNIT[s]), `graph terminal "${s}" must map to a done-tier canonical status`);
|
|
100
|
+
}
|
|
101
|
+
// The non-terminal parked waits must NOT be classified terminal (a reconciler must keep polling them).
|
|
102
|
+
for (const nonTerminal of ["escalated", "awaiting_operator", "waiting", "running", "requested"] as DeliveryUnitStatus[]) {
|
|
103
|
+
assert(!isDeliveryUnitTerminal(nonTerminal), `"${nonTerminal}" must be non-terminal`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("NODE-VS-AGGREGATE decision (ADR 0006 §4): a plan-task node maps into the SAME union; its lane wait becomes the canonical `waiting`", () => {
|
|
108
|
+
assertEquals(PLAN_TASK_STATUS_TO_UNIT["waiting-for-lane"], "waiting", "the node lane/dependency wait is the canonical `waiting`");
|
|
109
|
+
assertEquals(PLAN_TASK_STATUS_TO_UNIT.pending, "requested", "a queued (not-yet-run) node is pre-dispatch `requested`");
|
|
110
|
+
// Every node status resolves to a member of the one canonical union — no separate node vocabulary.
|
|
111
|
+
for (const s of PLAN_TASK_STATUSES) {
|
|
112
|
+
assert(CANONICAL.has(PLAN_TASK_STATUS_TO_UNIT[s]), `node status "${s}" must be a canonical member`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the settled set is exactly the terminal (done-tier) set plus the two live PR resting stages", () => {
|
|
117
|
+
assertEquals(
|
|
118
|
+
[...DELIVERY_UNIT_SETTLED_STATUSES].sort(),
|
|
119
|
+
[...DELIVERY_UNIT_TERMINAL_STATUSES, "opened", "converging"].sort(),
|
|
120
|
+
"settled = terminal ∪ {opened, converging}",
|
|
121
|
+
);
|
|
122
|
+
for (const t of DELIVERY_UNIT_TERMINAL_STATUSES) assert(isDeliveryUnitSettled(t), `terminal "${t}" is settled`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("no dead canonical members: every DELIVERY_UNIT_STATUSES value is reachable from at least one source mapping", () => {
|
|
126
|
+
const reached = new Set<string>();
|
|
127
|
+
for (const { map } of shapes) for (const v of Object.values(map)) reached.add(v as string);
|
|
128
|
+
for (const canonical of DELIVERY_UNIT_STATUSES) {
|
|
129
|
+
assert(reached.has(canonical), `canonical "${canonical}" is unreachable — a dead member or a missing mapping`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("FRAMEWORK PARITY: each per-shape delivery_status model's SQL and TS lowerings agree over the full source-status matrix", () => {
|
|
134
|
+
for (const { name, model, sources } of shapes) {
|
|
135
|
+
const samples: ParitySample[] = sources.map((status) => ({ baseRow: { status } }));
|
|
136
|
+
// Also exercise the ELSE NULL arm with an out-of-band value.
|
|
137
|
+
samples.push({ baseRow: { status: "out-of-band" } });
|
|
138
|
+
const db = new DatabaseSync(":memory:");
|
|
139
|
+
assertReadModelParity(model, parityDb(db), samples, { columns: [DELIVERY_STATUS_COLUMN] });
|
|
140
|
+
db.close();
|
|
141
|
+
assert(true, `${name} parity holds`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The ONE delivery-unit status union — ADR 0006 slice **S1** (status lifecycle).
|
|
2
|
+
//
|
|
3
|
+
// Background (ADR 0006, issue #464). nano-workforce models the same aggregate — a *scheduled unit of
|
|
4
|
+
// work driven to a delivery outcome* — in three separate representations, each with its OWN bespoke
|
|
5
|
+
// status union:
|
|
6
|
+
//
|
|
7
|
+
// * feature — `FEATURE_RUN_STATUSES` (11: running/escalated/opened/converging/awaiting_operator/
|
|
8
|
+
// merged/converged/blocked/skipped/failed/abandoned) — app/feature.ts
|
|
9
|
+
// * epic — the `plans` aggregate (`planning`/`dispatched`/`done`/`failed`/`abandoned`, the union
|
|
10
|
+
// of `EPIC_LIVE_STATUSES` + `PLAN_TERMINAL_STATUSES`, app/delivery.ts + app/plan.ts)
|
|
11
|
+
// AND its `plan_tasks` NODE status `PLAN_TASK_STATUSES` (7) — app/plan.ts
|
|
12
|
+
// * graph — `DELIVERY_GRAPH_RUN_STATUSES` (5: awaiting-approval/running/done/failed/abandoned)
|
|
13
|
+
// — app/deliveryGraphRun.ts
|
|
14
|
+
//
|
|
15
|
+
// A change to "what states a unit of work can be in" therefore has to be made, by hand, in three (four,
|
|
16
|
+
// counting the epic's two levels) places that can silently drift — exactly the "No drift surfaces /
|
|
17
|
+
// derivation over duplication" hazard this repo treats as a defect class (AGENTS.md).
|
|
18
|
+
//
|
|
19
|
+
// This module is S1's deliverable: it defines the SINGLE canonical aggregate union
|
|
20
|
+
// ({@link DELIVERY_UNIT_STATUSES}) and, via ADR-0065's `defineReadModel`, the per-shape derivations that
|
|
21
|
+
// map each bespoke union INTO it — declared ONCE and compiled to BOTH the SQLite VIEW select-list
|
|
22
|
+
// (`sqlSelectFor`, for S2's `delivery_units`-backed VIEWs) AND the runtime TS oracle (`fnFor`, for the
|
|
23
|
+
// reconcilers). There is nothing to keep in lockstep: the two lowerings fall out of the same closed-DSL
|
|
24
|
+
// AST, and `assertReadModelParity` (app/deliveryUnitStatus.test.ts) proves they agree.
|
|
25
|
+
//
|
|
26
|
+
// SCOPE (S1). This slice OWNS the canonical vocabulary, the per-shape mapping, the terminal/settled
|
|
27
|
+
// precedence, and the node-vs-aggregate decision (below). It does NOT repoint any existing VIEW or move
|
|
28
|
+
// any writer/`instanceTracking` binding onto the new union — the legacy tables stay the physical write
|
|
29
|
+
// target through S2, and the `instanceTracking` bindings + `senior:*` doors collapse in S3 (ADR 0006
|
|
30
|
+
// rollout). The derivations here are the single source those later slices reference, not a second
|
|
31
|
+
// projection alongside them.
|
|
32
|
+
|
|
33
|
+
import { caseWhen, col, defineReadModel, type Expr, eq, lit, type ReadModel, when } from "@nanobpm/urban";
|
|
34
|
+
import { EPIC_LIVE_STATUSES } from "./delivery.ts";
|
|
35
|
+
import type { DeliveryGraphRunStatus } from "./deliveryGraphRun.ts";
|
|
36
|
+
import type { FeatureRunStatus } from "./feature.ts";
|
|
37
|
+
import { PLAN_TERMINAL_STATUSES, type PlanTaskStatus } from "./plan.ts";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The ONE canonical delivery-unit status union — the single source of truth for "what state a unit of
|
|
41
|
+
* work is in", replacing the three bespoke unions. A superset that preserves every source union's
|
|
42
|
+
* distinctions without loss (feature is the reference shape, so its members pass through by name):
|
|
43
|
+
*
|
|
44
|
+
* - `requested` — created, not yet dispatched to an executor (no live engine instance yet).
|
|
45
|
+
* - `running` — an executor (agent/probe/connector) is actively working the unit.
|
|
46
|
+
* - `escalated` — NON-terminal: parked awaiting a HUMAN answer (an open escalation user task).
|
|
47
|
+
* - `awaiting_operator` — NON-terminal: parked awaiting an OPERATOR acknowledgement (blocked wait).
|
|
48
|
+
* - `waiting` — NON-terminal: parked on a lane / dependency gate (a wave barrier).
|
|
49
|
+
* - `opened` — a PR was raised and the unit rests here (convergence was not requested).
|
|
50
|
+
* - `converging` — the opened PR is in its review-convergence loop.
|
|
51
|
+
* - `converged` — TERMINAL: review converged but the PR did not merge (auto-merge off).
|
|
52
|
+
* - `merged` — TERMINAL: the PR landed (the win).
|
|
53
|
+
* - `done` — TERMINAL: an aggregate settled successfully WITHOUT a single-PR terminal
|
|
54
|
+
* (an epic/graph whose members all landed) — the PR-less success outcome.
|
|
55
|
+
* - `skipped` — TERMINAL: nothing to do.
|
|
56
|
+
* - `blocked` — TERMINAL: could not proceed / gave up (distinct from the non-terminal
|
|
57
|
+
* `awaiting_operator` wait — a `blocked` unit is settled, not parked).
|
|
58
|
+
* - `failed` — TERMINAL: an unexpected failure.
|
|
59
|
+
* - `abandoned` — TERMINAL: the PR was abandoned, or the process instance was cancelled.
|
|
60
|
+
*/
|
|
61
|
+
export const DELIVERY_UNIT_STATUSES = [
|
|
62
|
+
"requested",
|
|
63
|
+
"running",
|
|
64
|
+
"escalated",
|
|
65
|
+
"awaiting_operator",
|
|
66
|
+
"waiting",
|
|
67
|
+
"opened",
|
|
68
|
+
"converging",
|
|
69
|
+
"converged",
|
|
70
|
+
"merged",
|
|
71
|
+
"done",
|
|
72
|
+
"skipped",
|
|
73
|
+
"blocked",
|
|
74
|
+
"failed",
|
|
75
|
+
"abandoned",
|
|
76
|
+
] as const;
|
|
77
|
+
export type DeliveryUnitStatus = (typeof DELIVERY_UNIT_STATUSES)[number];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The TRULY-terminal ("done tier") statuses — a unit in one of these has settled to a final outcome and
|
|
81
|
+
* will not advance again. Mirrors the union of the source terminal sets (`FEATURE_TERMINAL_STATUSES`
|
|
82
|
+
* minus its live PR stages, `PLAN_TERMINAL_STATUSES`, `DELIVERY_GRAPH_TERMINAL_STATUSES`). Distinct from
|
|
83
|
+
* {@link DELIVERY_UNIT_SETTLED_STATUSES}: `opened`/`converging` are settled FOR RE-DISPATCH but are LIVE
|
|
84
|
+
* pipeline stages, not `done`.
|
|
85
|
+
*/
|
|
86
|
+
export const DELIVERY_UNIT_TERMINAL_STATUSES: readonly DeliveryUnitStatus[] = [
|
|
87
|
+
"converged",
|
|
88
|
+
"merged",
|
|
89
|
+
"done",
|
|
90
|
+
"skipped",
|
|
91
|
+
"blocked",
|
|
92
|
+
"failed",
|
|
93
|
+
"abandoned",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The SETTLED-FOR-RE-DISPATCH statuses — {@link DELIVERY_UNIT_TERMINAL_STATUSES} plus the two live PR
|
|
98
|
+
* resting stages (`opened`/`converging`) a unit stops at without a further wave restart. Mirrors
|
|
99
|
+
* `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which likewise counts `opened`/`converging` as terminal
|
|
100
|
+
* for re-dispatch gating even though they are LIVE (not `done`). A re-dispatch of the same unit
|
|
101
|
+
* short-circuits IFF its prior run is in one of these; the NON-terminal parked waits
|
|
102
|
+
* (`escalated`/`awaiting_operator`/`waiting`) and `running`/`requested` are excluded, so a live or
|
|
103
|
+
* parked unit is never orphaned by a parallel restart.
|
|
104
|
+
*/
|
|
105
|
+
export const DELIVERY_UNIT_SETTLED_STATUSES: readonly DeliveryUnitStatus[] = [
|
|
106
|
+
...DELIVERY_UNIT_TERMINAL_STATUSES,
|
|
107
|
+
"opened",
|
|
108
|
+
"converging",
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** True iff `status` is a truly-terminal ("done tier") delivery-unit status. */
|
|
112
|
+
export const isDeliveryUnitTerminal = (status: DeliveryUnitStatus): boolean =>
|
|
113
|
+
DELIVERY_UNIT_TERMINAL_STATUSES.includes(status);
|
|
114
|
+
|
|
115
|
+
/** True iff `status` is settled for RE-DISPATCH (terminal, or a live PR resting stage). */
|
|
116
|
+
export const isDeliveryUnitSettled = (status: DeliveryUnitStatus): boolean =>
|
|
117
|
+
DELIVERY_UNIT_SETTLED_STATUSES.includes(status);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The plan AGGREGATE lifecycle values — the union of the two existing sources (`EPIC_LIVE_STATUSES` +
|
|
121
|
+
* `PLAN_TERMINAL_STATUSES`), NOT re-listed here, so this stays a derived view of them and cannot drift.
|
|
122
|
+
*/
|
|
123
|
+
export const PLAN_STATUSES = [...EPIC_LIVE_STATUSES, ...PLAN_TERMINAL_STATUSES] as const;
|
|
124
|
+
|
|
125
|
+
// ── Per-shape mappings — declared ONCE, keyed by the SOURCE union so `tsc` fails if a source union
|
|
126
|
+
// gains a member without a canonical mapping (the type-level No-Drift guard). ──────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Feature is the REFERENCE shape: every `FEATURE_RUN_STATUSES` member has a same-named canonical member,
|
|
130
|
+
* so the map is the identity. The distinct feature waits survive intact — `escalated` (human),
|
|
131
|
+
* `awaiting_operator` (operator), and the terminal `blocked` (gave up) stay three different states.
|
|
132
|
+
*/
|
|
133
|
+
export const FEATURE_STATUS_TO_UNIT: Record<FeatureRunStatus, DeliveryUnitStatus> = {
|
|
134
|
+
running: "running",
|
|
135
|
+
escalated: "escalated",
|
|
136
|
+
opened: "opened",
|
|
137
|
+
converging: "converging",
|
|
138
|
+
awaiting_operator: "awaiting_operator",
|
|
139
|
+
merged: "merged",
|
|
140
|
+
converged: "converged",
|
|
141
|
+
blocked: "blocked",
|
|
142
|
+
skipped: "skipped",
|
|
143
|
+
failed: "failed",
|
|
144
|
+
abandoned: "abandoned",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Plan AGGREGATE → canonical. `planning` (decomposing, no fan-out yet) is pre-dispatch ⇒ `requested`;
|
|
149
|
+
* `dispatched` (fan-out running) ⇒ `running`; the three terminals pass through. Keyed by the plan
|
|
150
|
+
* aggregate value (a bare string in `Plan.status`), covering every {@link PLAN_STATUSES} member.
|
|
151
|
+
*/
|
|
152
|
+
export const PLAN_STATUS_TO_UNIT: Record<(typeof PLAN_STATUSES)[number], DeliveryUnitStatus> = {
|
|
153
|
+
planning: "requested",
|
|
154
|
+
dispatched: "running",
|
|
155
|
+
done: "done",
|
|
156
|
+
failed: "failed",
|
|
157
|
+
abandoned: "abandoned",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Plan-task NODE → canonical. **Node-vs-aggregate decision (ADR 0006 §4):** a node is a DEGENERATE
|
|
162
|
+
* delivery unit, so its status maps into the SAME canonical union rather than carrying a separate node
|
|
163
|
+
* contract — there is ONE vocabulary. The node-specific lane/dependency wait (`waiting-for-lane`) is
|
|
164
|
+
* expressed by the canonical `waiting` member (a state the aggregate level never enters); `pending`
|
|
165
|
+
* (queued, not yet run in its wave) is pre-dispatch ⇒ `requested`.
|
|
166
|
+
*/
|
|
167
|
+
export const PLAN_TASK_STATUS_TO_UNIT: Record<PlanTaskStatus, DeliveryUnitStatus> = {
|
|
168
|
+
pending: "requested",
|
|
169
|
+
opened: "opened",
|
|
170
|
+
blocked: "blocked",
|
|
171
|
+
skipped: "skipped",
|
|
172
|
+
escalated: "escalated",
|
|
173
|
+
"waiting-for-lane": "waiting",
|
|
174
|
+
abandoned: "abandoned",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Delivery-graph RUN → canonical. `awaiting-approval` (reserved, pre-dispatch: no live instance, parked
|
|
179
|
+
* before launch — issue #460) ⇒ `requested`; `running` passes through; the three terminals pass through.
|
|
180
|
+
*/
|
|
181
|
+
export const DELIVERY_GRAPH_STATUS_TO_UNIT: Record<DeliveryGraphRunStatus, DeliveryUnitStatus> = {
|
|
182
|
+
"awaiting-approval": "requested",
|
|
183
|
+
running: "running",
|
|
184
|
+
done: "done",
|
|
185
|
+
failed: "failed",
|
|
186
|
+
abandoned: "abandoned",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the canonical `delivery_status` derivation for one shape as a closed-DSL {@link Expr}: a
|
|
191
|
+
* `CASE` over the base row's `status` column, one `WHEN status = '<source>' THEN '<canonical>'` per map
|
|
192
|
+
* entry. The map is TOTAL over its source union, so the `ELSE` is unreachable in practice; it falls back
|
|
193
|
+
* to `null` (never an invented status) so an out-of-band source value surfaces as NULL rather than a
|
|
194
|
+
* silent mis-map. Both backends (`sqlSelectFor` VIEW body, `fnFor` runtime) fall out of this one AST.
|
|
195
|
+
*/
|
|
196
|
+
export const deliveryStatusExpr = (map: Readonly<Record<string, DeliveryUnitStatus>>): Expr =>
|
|
197
|
+
caseWhen(
|
|
198
|
+
Object.entries(map).map(([source, unit]) => when(eq(col("status"), lit(source)), lit(unit))),
|
|
199
|
+
lit(null),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/** The single derived column every per-shape delivery-unit read model exposes. */
|
|
203
|
+
export const DELIVERY_STATUS_COLUMN = "delivery_status";
|
|
204
|
+
|
|
205
|
+
/** The base alias the managed VIEWs give each source table — pinned so emitted SQL is stable/testable. */
|
|
206
|
+
export const DELIVERY_UNIT_STATUS_BASE_ALIAS = "du";
|
|
207
|
+
|
|
208
|
+
const statusReadModel = (name: string, baseTable: string, map: Readonly<Record<string, DeliveryUnitStatus>>): ReadModel =>
|
|
209
|
+
defineReadModel({
|
|
210
|
+
name,
|
|
211
|
+
baseTable,
|
|
212
|
+
selectBaseColumns: false,
|
|
213
|
+
derive: { [DELIVERY_STATUS_COLUMN]: deliveryStatusExpr(map) },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The four per-shape derivations of the ONE canonical union, each a `defineReadModel` exposing a single
|
|
218
|
+
* `delivery_status` column over its source table. S2 provisions these as VIEWs over `delivery_units`;
|
|
219
|
+
* the reconcilers consume `fnFor(DELIVERY_STATUS_COLUMN)`. They all target {@link DELIVERY_UNIT_STATUSES}
|
|
220
|
+
* — the union is single-sourced; only the per-shape MAPPING differs.
|
|
221
|
+
*/
|
|
222
|
+
export const featureDeliveryStatus: ReadModel = statusReadModel("feature_delivery_status", "feature_runs", FEATURE_STATUS_TO_UNIT);
|
|
223
|
+
export const planDeliveryStatus: ReadModel = statusReadModel("plan_delivery_status", "plans", PLAN_STATUS_TO_UNIT);
|
|
224
|
+
export const planTaskDeliveryStatus: ReadModel = statusReadModel("plan_task_delivery_status", "plan_tasks", PLAN_TASK_STATUS_TO_UNIT);
|
|
225
|
+
export const deliveryGraphDeliveryStatus: ReadModel = statusReadModel("delivery_graph_delivery_status", "delivery_graph_runs", DELIVERY_GRAPH_STATUS_TO_UNIT);
|
|
226
|
+
|
|
227
|
+
/** All four per-shape delivery-status read models, for bulk registration/validation by later slices. */
|
|
228
|
+
export const DELIVERY_STATUS_READ_MODELS: readonly ReadModel[] = [
|
|
229
|
+
featureDeliveryStatus,
|
|
230
|
+
planDeliveryStatus,
|
|
231
|
+
planTaskDeliveryStatus,
|
|
232
|
+
deliveryGraphDeliveryStatus,
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Map a source status to the canonical union in-process (the TS backend of {@link deliveryStatusExpr},
|
|
237
|
+
* via the compiled `fnFor`) — the reconciler-facing helper. Returns `null` for an unmapped value,
|
|
238
|
+
* matching the VIEW's `ELSE NULL`.
|
|
239
|
+
*/
|
|
240
|
+
export const toDeliveryUnitStatus = (model: ReadModel, status: string | null): DeliveryUnitStatus | null =>
|
|
241
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary — `fnFor` returns `unknown`; the derived column yields one of its declared `lit(...)` canonical statuses (or null on the ELSE arm).
|
|
242
|
+
model.fnFor(DELIVERY_STATUS_COLUMN)({ status }) as DeliveryUnitStatus | null;
|
package/app/plan.ts
CHANGED
|
@@ -445,7 +445,7 @@ export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS",
|
|
|
445
445
|
|
|
446
446
|
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
447
447
|
* is in flight. The cancel guard and the active view key off this. */
|
|
448
|
-
export const PLAN_TERMINAL_STATUSES
|
|
448
|
+
export const PLAN_TERMINAL_STATUSES = ["done", "failed", "abandoned"] as const;
|
|
449
449
|
|
|
450
450
|
export interface ParsedIssue {
|
|
451
451
|
repo: string;
|
|
@@ -591,7 +591,7 @@ export async function findActivePlansByBase(
|
|
|
591
591
|
base: string,
|
|
592
592
|
): Promise<Plan[]> {
|
|
593
593
|
const rows = await plans(data).find({ repo, base_branch: base });
|
|
594
|
-
return rows.filter((p) => !PLAN_TERMINAL_STATUSES.
|
|
594
|
+
return rows.filter((p) => !PLAN_TERMINAL_STATUSES.some((s) => s === p.status));
|
|
595
595
|
}
|
|
596
596
|
|
|
597
597
|
/** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
|
|
@@ -926,7 +926,7 @@ export async function startPlan(
|
|
|
926
926
|
}
|
|
927
927
|
const table = plans(data);
|
|
928
928
|
const existing = await table.get(parsed.planKey);
|
|
929
|
-
if (existing && !PLAN_TERMINAL_STATUSES.
|
|
929
|
+
if (existing && !PLAN_TERMINAL_STATUSES.some((s) => s === existing.status)) {
|
|
930
930
|
return { planKey: parsed.planKey, alreadyRunning: true };
|
|
931
931
|
}
|
|
932
932
|
const base = normalizeBaseBranch(baseBranch);
|
|
@@ -225,6 +225,31 @@ resume cannot double-fire.
|
|
|
225
225
|
- **Non-npm emit facts** (OCI/github-release) and behavioural edges beyond the `command` escape hatch —
|
|
226
226
|
added when a real case lands.
|
|
227
227
|
|
|
228
|
+
> **Amendment (issue #492): conditional (guarded) edges landed (S7).** The "behavioural edges" deferral
|
|
229
|
+
> above is **partially lifted**: an edge may now carry an optional guard — `when: "<node>.<fact>"` +
|
|
230
|
+
> `equals: <scalar>` — or be the split's single `default: true` else-branch. A node whose out-edges
|
|
231
|
+
> carry guards is an **exclusive** (data-based) split instead of the default parallel fan-out; its guarded branches compile to a BPMN `exclusiveGateway` (`gwx<i>`) with one FEEL
|
|
232
|
+
> `conditionExpression` per guarded flow (`=<producerElement>_<fact> = <literal>`) and a named default
|
|
233
|
+
> flow, and where those branches re-converge they merge on an **exclusive** gateway (`gwm<i>`,
|
|
234
|
+
> first-token-proceeds) rather than the parallel AND-join that would deadlock the untaken branch. The
|
|
235
|
+
> validator enforces the guard's shape and closes the deadlock/ambiguity classes: a guard must
|
|
236
|
+
> reference a **scalar** fact **declared by the edge's own producer** (`bad-when`), carry an `equals`
|
|
237
|
+
> whose type matches the fact (`guard-missing-equals` / `guard-type-mismatch`), never combine `when`
|
|
238
|
+
> with `default` (`guard-default-conflict`); a split must not **mix** guarded and plain out-edges
|
|
239
|
+
> (`mixed-fan-out`) nor declare **two** defaults (`multiple-defaults`), must be **exhaustive** — a
|
|
240
|
+
> `default`, unless a single boolean fact is guarded on both `true` and `false` (`non-exhaustive-split`)
|
|
241
|
+
> — and a plain (parallel) AND-join may not be fed by an exclusive-split branch (`exclusive-merge-parity`),
|
|
242
|
+
> including the implicit **End sink**: the terminal nodes may not mix a conditional (exclusive-split)
|
|
243
|
+
> tail with an always-firing one, which would deadlock the End join or double-fire its exclusive merge.
|
|
244
|
+
> The exclusive-split topology (both the validator's parity analysis and the compiler's gateway
|
|
245
|
+
> selection) is derived from the **guarded (`when`) edges fanning out to \>=2 distinct downstream
|
|
246
|
+
> targets only** — a lone `default: true` edge with no guarded sibling always fires, and a node whose
|
|
247
|
+
> guarded + `default` edges all converge on **one** downstream target has no real fan-out, so in
|
|
248
|
+
> either case its producer is **not** a split and must not mark downstream nodes/leaves conditional;
|
|
249
|
+
> the per-node mixing/exhaustiveness checks still apply to any `default`.
|
|
250
|
+
> Determinism is preserved: gateway ids are positional over id-sorted nodes, so a graph with no guards
|
|
251
|
+
> compiles byte-for-byte as before.
|
|
252
|
+
|
|
228
253
|
## Open questions
|
|
229
254
|
|
|
230
255
|
- **Compiler target for the first cut** — confirm compile-to-native (diagram + native scheduling) vs a
|
package/openapi.yaml
CHANGED
|
@@ -1502,7 +1502,9 @@ components:
|
|
|
1502
1502
|
`from` is either a bare `<nodeId>` (wait for the upstream node's completion fact) or a
|
|
1503
1503
|
qualified `<nodeId>.<fact>` referencing a declared `emits` fact of that node. Both endpoints
|
|
1504
1504
|
must resolve to a node in the graph, the referenced fact must be declared, and the whole edge
|
|
1505
|
-
set must be a DAG — all enforced by `validateDeliveryGraph`.
|
|
1505
|
+
set must be a DAG — all enforced by `validateDeliveryGraph`. An OPTIONAL `when`/`equals` guard
|
|
1506
|
+
(or a `default` else-branch) makes the edge CONDITIONAL, turning its producer into an
|
|
1507
|
+
exclusive split (ADR 0005 S7) — a node's out-edges are then ALL guarded or ALL unconditional.
|
|
1506
1508
|
type: object
|
|
1507
1509
|
additionalProperties: false
|
|
1508
1510
|
required:
|
|
@@ -1517,6 +1519,33 @@ components:
|
|
|
1517
1519
|
type: string
|
|
1518
1520
|
minLength: 1
|
|
1519
1521
|
description: The dependent node's id — proceeds once `from` is observed.
|
|
1522
|
+
when:
|
|
1523
|
+
type: string
|
|
1524
|
+
minLength: 1
|
|
1525
|
+
description: >-
|
|
1526
|
+
OPTIONAL guard reference `<nodeId>.<fact>` naming a SCALAR emitted fact (`string`,
|
|
1527
|
+
`number`, or `boolean`) of the `from`-adjacent producer (ADR 0005 S7). Its presence makes
|
|
1528
|
+
this a GUARDED edge and turns the producer into an exclusive-split point: the edge is taken
|
|
1529
|
+
only when that runtime fact `equals` the literal below. Equality-only — no arbitrary
|
|
1530
|
+
expressions (the trust boundary). Mutually exclusive with `default`.
|
|
1531
|
+
equals:
|
|
1532
|
+
description: >-
|
|
1533
|
+
The literal value `when`'s fact must equal for this guarded edge to be taken (ADR 0005 S7).
|
|
1534
|
+
REQUIRED iff `when` is present, and its JSON type must match the referenced fact's declared
|
|
1535
|
+
type (`string`/`number`/`boolean`).
|
|
1536
|
+
oneOf:
|
|
1537
|
+
- type: string
|
|
1538
|
+
- type: number
|
|
1539
|
+
- type: boolean
|
|
1540
|
+
default:
|
|
1541
|
+
type: boolean
|
|
1542
|
+
enum: [true]
|
|
1543
|
+
description: >-
|
|
1544
|
+
OPTIONAL — marks this edge as the ELSE branch of the exclusive split (taken when no guarded
|
|
1545
|
+
edge matches at runtime). A FLAG: only `true` is meaningful, so it is constrained to `true`
|
|
1546
|
+
(omit the field entirely for a non-default edge — `default: false` is not a valid wire
|
|
1547
|
+
value). At most one `default` edge per split node. Mutually exclusive with `when`/`equals`
|
|
1548
|
+
(ADR 0005 S7).
|
|
1520
1549
|
DeliveryCompileError:
|
|
1521
1550
|
description: >-
|
|
1522
1551
|
One semantic-validation or compile failure, path-qualified at the offending input
|
|
@@ -1749,6 +1778,19 @@ components:
|
|
|
1749
1778
|
fromFact:
|
|
1750
1779
|
type: string
|
|
1751
1780
|
description: The referenced emitted fact, when the edge `from` was qualified.
|
|
1781
|
+
when:
|
|
1782
|
+
type: string
|
|
1783
|
+
description: The guard reference (`<nodeId>.<fact>`) verbatim, present only on a guarded edge (ADR 0005 S7).
|
|
1784
|
+
equals:
|
|
1785
|
+
description: The literal the guard fact must equal, present only on a guarded edge (ADR 0005 S7).
|
|
1786
|
+
oneOf:
|
|
1787
|
+
- type: string
|
|
1788
|
+
- type: number
|
|
1789
|
+
- type: boolean
|
|
1790
|
+
default:
|
|
1791
|
+
type: boolean
|
|
1792
|
+
enum: [true]
|
|
1793
|
+
description: True when this is the exclusive split's default (else) branch; a FLAG, only ever `true` and omitted otherwise (ADR 0005 S7).
|
|
1752
1794
|
ResolvedDeliveryGraph:
|
|
1753
1795
|
description: >-
|
|
1754
1796
|
The normalised graph the compiler resolved from the input (ADR 0005 slice S1) — nodes and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.131.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -173,6 +173,18 @@
|
|
|
173
173
|
border-radius: 6px;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/* The "connected — waiting for output" status note under the terminal (blank-terminal defect fix):
|
|
177
|
+
hidden until a live drill is connected-but-quiet, so a blank panel reads as "waiting" not "broken". */
|
|
178
|
+
.cockpit-terminal-note {
|
|
179
|
+
margin: 8px 0 0;
|
|
180
|
+
font-size: 0.85em;
|
|
181
|
+
color: #7c8794;
|
|
182
|
+
font-style: italic;
|
|
183
|
+
}
|
|
184
|
+
.cockpit-terminal-note[data-terminal-note="none"] {
|
|
185
|
+
display: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
/* ── Past sessions (H3 read path / #222): the captured-session history + replay. ──────────────── */
|
|
177
189
|
|
|
178
190
|
.cockpit-past-header {
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -72,6 +72,7 @@ function workerView(worker, staleAfterMs, byJobKey) {
|
|
|
72
72
|
host: worker.host ?? "\u2014",
|
|
73
73
|
jobKeys,
|
|
74
74
|
jobs: jobKeys.length,
|
|
75
|
+
drillable: jobKeys.length > 0,
|
|
75
76
|
correlations,
|
|
76
77
|
liveness: liveness(worker, staleAfterMs),
|
|
77
78
|
staleMs: worker.staleMs,
|
|
@@ -141,12 +142,18 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
141
142
|
button.setAttribute("data-stream", worker.stream);
|
|
142
143
|
if (onOpenWorker) button.addEventListener("click", () => onOpenWorker(worker.instance));
|
|
143
144
|
nameCell.appendChild(button);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
145
|
+
// The inline live-terminal drill — ONLY for a worker that currently holds a job. An idle worker's
|
|
146
|
+
// `stream` is its bare instance id, which no producer writes to, so drilling it opens a permanently
|
|
147
|
+
// blank "live" terminal. Suppress the affordance when there is nothing live to stream (mirrors
|
|
148
|
+
// app/agentic/cockpit/supply-render.ts).
|
|
149
|
+
if (worker.drillable) {
|
|
150
|
+
const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
|
|
151
|
+
drill.setAttribute("type", "button");
|
|
152
|
+
drill.setAttribute("data-instance", worker.instance);
|
|
153
|
+
drill.setAttribute("data-stream", worker.stream);
|
|
154
|
+
if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
|
|
155
|
+
nameCell.appendChild(drill);
|
|
156
|
+
}
|
|
150
157
|
row.appendChild(nameCell);
|
|
151
158
|
|
|
152
159
|
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
|
@@ -494,6 +501,12 @@ export function mountCockpit(host, opts = {}) {
|
|
|
494
501
|
const terminalHost = el(doc, "div", "cockpit-terminal-host");
|
|
495
502
|
terminalHost.setAttribute("data-terminal", "host");
|
|
496
503
|
terminalPanel.appendChild(terminalHost);
|
|
504
|
+
// Status note under the terminal, shown while a LIVE drill has connected but no output has arrived
|
|
505
|
+
// yet (a quiet job between frames), so a blank panel reads as "waiting" not "broken". Cleared on
|
|
506
|
+
// the first frame and on every mode change (mirrors app/agentic/cockpit/supply-boot.ts).
|
|
507
|
+
const terminalNote = el(doc, "p", "cockpit-terminal-note");
|
|
508
|
+
terminalNote.setAttribute("data-terminal-note", "none");
|
|
509
|
+
terminalPanel.appendChild(terminalNote);
|
|
497
510
|
shell.appendChild(listRegion);
|
|
498
511
|
shell.appendChild(pastRegion);
|
|
499
512
|
shell.appendChild(terminalPanel);
|
|
@@ -524,6 +537,19 @@ export function mountCockpit(host, opts = {}) {
|
|
|
524
537
|
if (next === "live") terminalTitle.textContent = "Worker terminal — live";
|
|
525
538
|
else if (next === "replay") terminalTitle.textContent = "Worker terminal — replay (past session)";
|
|
526
539
|
else terminalTitle.textContent = "Worker terminal";
|
|
540
|
+
// Any mode change replaces what's behind the panel, so the prior "waiting" note is stale — clear
|
|
541
|
+
// it. A live drill re-arms it once its fresh terminal is mounted.
|
|
542
|
+
setNote(undefined);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function setNote(text) {
|
|
546
|
+
if (text == null) {
|
|
547
|
+
terminalNote.textContent = "";
|
|
548
|
+
terminalNote.setAttribute("data-terminal-note", "none");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
terminalNote.textContent = text;
|
|
552
|
+
terminalNote.setAttribute("data-terminal-note", "waiting");
|
|
527
553
|
}
|
|
528
554
|
|
|
529
555
|
function teardownTerminal() {
|
|
@@ -586,8 +612,19 @@ export function mountCockpit(host, opts = {}) {
|
|
|
586
612
|
teardownTerminal();
|
|
587
613
|
try {
|
|
588
614
|
terminalHost.replaceChildren();
|
|
589
|
-
const
|
|
590
|
-
terminal =
|
|
615
|
+
const rawSink = xtermSink(terminalHost);
|
|
616
|
+
terminal = rawSink;
|
|
617
|
+
// Wrap the sink so the first byte of live output clears the "waiting" note (mirrors supply-boot).
|
|
618
|
+
let cleared = false;
|
|
619
|
+
const sink = {
|
|
620
|
+
write: (chunk) => {
|
|
621
|
+
if (!cleared) {
|
|
622
|
+
cleared = true;
|
|
623
|
+
setNote(undefined);
|
|
624
|
+
}
|
|
625
|
+
rawSink.write(chunk);
|
|
626
|
+
},
|
|
627
|
+
};
|
|
591
628
|
let session;
|
|
592
629
|
const client = new RelayChannelClient({
|
|
593
630
|
connect: connectRelay,
|
|
@@ -599,6 +636,8 @@ export function mountCockpit(host, opts = {}) {
|
|
|
599
636
|
client.open();
|
|
600
637
|
drill = { stream, client };
|
|
601
638
|
setMode("live", stream);
|
|
639
|
+
// Arm the "waiting for output" note (after setMode, which clears it) until the first frame.
|
|
640
|
+
setNote("Connected — waiting for live output…");
|
|
602
641
|
} catch (err) {
|
|
603
642
|
// The new terminal failed to build after the prior one was torn down: reset the region to idle
|
|
604
643
|
// (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
|