@nanobpm/nano-workforce 0.130.0 → 0.131.1

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.
@@ -207,3 +207,151 @@ test("di coverage: every compiled flow node carries a BPMNShape and every sequen
207
207
  function escapeRe(s: string): string {
208
208
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
209
  }
210
+
211
+ // ── S7: guarded (conditional) routing DEPLOYS and ROUTES on the real engine (ADR 0005 S7) ──────────
212
+ // The compiler tests prove a guarded split emits an exclusiveGateway with FEEL conditions; only a live
213
+ // deploy proves the engine EVALUATES those conditions and takes exactly ONE branch. Here the `bump`
214
+ // agent's emitted scalar (`result`) is published to a process var and the exclusive gateway routes on
215
+ // it: the breaking outcome runs the `migrate` node, the green outcome skips straight to `release`, and
216
+ // BOTH re-converge on the exclusive merge to a COMPLETED instance (a parallel merge would deadlock the
217
+ // skipped branch). A guarded string fact needs a `default`, so green rides the else-flow.
218
+ const GUARDED_ADOPT: DeliveryGraph = {
219
+ name: "adopt runbook",
220
+ nodes: [
221
+ { id: "bump", kind: "agent", agent: { jobType: "senior:bump" }, emits: [{ name: "result", type: "string" }] },
222
+ { id: "migrate", kind: "agent", agent: { jobType: "senior:migrate" } },
223
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "rel-1" } },
224
+ ],
225
+ edges: [
226
+ { from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
227
+ { from: "bump", to: "release", default: true },
228
+ { from: "migrate", to: "release" },
229
+ ],
230
+ };
231
+
232
+ async function driveGuarded(outcome: "breaking" | "green"): Promise<{ state: string; migrateRan: boolean; releaseRan: boolean }> {
233
+ const engine = await createWasmEngineClient();
234
+ try {
235
+ let migrateRan = false;
236
+ let releaseRan = false;
237
+ // The split agent publishes its scalar outcome; the exclusive gateway routes on it.
238
+ await engine.registerWorker("senior:bump", async () => ({ result: outcome }));
239
+ await engine.registerWorker("senior:migrate", async () => {
240
+ migrateRan = true;
241
+ return {};
242
+ });
243
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
244
+ releaseRan = true;
245
+ return {};
246
+ });
247
+
248
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT);
249
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
250
+ const key = run.handle.processInstanceKey;
251
+
252
+ let state = "?";
253
+ for (let round = 0; round < MAX_ROUNDS; round++) {
254
+ await engine.drain();
255
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
256
+ assert(pi, `no process instance snapshot for ${key}`);
257
+ state = pi.state ?? "?";
258
+ if (state === "COMPLETED" || state === "TERMINATED") break;
259
+ }
260
+ return { state, migrateRan, releaseRan };
261
+ } finally {
262
+ await engine.close();
263
+ }
264
+ }
265
+
266
+ test("S7 deploy+route: the breaking guard branch runs `migrate` before re-converging on the exclusive merge to COMPLETED", async () => {
267
+ const r = await driveGuarded("breaking");
268
+ assertEquals(r.state, "COMPLETED", "the breaking branch must run to a COMPLETED instance");
269
+ assert(r.migrateRan, "the breaking outcome must route through the guarded `migrate` node");
270
+ assert(r.releaseRan, "both branches must re-converge on `release`");
271
+ });
272
+
273
+ test("S7 deploy+route: the green default branch SKIPS `migrate` and rides the else-flow straight to COMPLETED", async () => {
274
+ const r = await driveGuarded("green");
275
+ assertEquals(r.state, "COMPLETED", "the green branch must run to a COMPLETED instance");
276
+ assert(!r.migrateRan, "the green outcome must NOT route through `migrate` — it rides the default flow");
277
+ assert(r.releaseRan, "the green outcome still reaches `release` via the else-flow (proof the exclusive merge fires on one token)");
278
+ });
279
+
280
+ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge — the untaken leaf never blocks completion", async () => {
281
+ // Mode D: `adopt` routes a missing surface to an escalate (human) leaf, else to a `done` connector
282
+ // leaf. On the default path the escalate leaf never fires; an exclusive End merge must still let the
283
+ // instance COMPLETE (a parallel End join would wait forever on the untaken human leaf).
284
+ const graph: DeliveryGraph = {
285
+ name: "surface check",
286
+ nodes: [
287
+ { id: "adopt", kind: "agent", agent: { jobType: "senior:adopt" }, emits: [{ name: "surface", type: "string" }] },
288
+ { id: "escalate", kind: "human", human: { prompt: "file the upstream issue" } },
289
+ { id: "done", kind: "connector", connector: { target: "npm:install", dedupeKey: "done-1" } },
290
+ ],
291
+ edges: [
292
+ { from: "adopt", to: "escalate", when: "adopt.surface", equals: "missing" },
293
+ { from: "adopt", to: "done", default: true },
294
+ ],
295
+ };
296
+
297
+ // Default path (surface present): the human leaf is skipped and the instance COMPLETES on its own.
298
+ {
299
+ const engine = await createWasmEngineClient();
300
+ try {
301
+ let doneRan = false;
302
+ await engine.registerWorker("senior:adopt", async () => ({ surface: "present" }));
303
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
304
+ doneRan = true;
305
+ return {};
306
+ });
307
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
308
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
309
+ const key = run.handle.processInstanceKey;
310
+ let state = "?";
311
+ for (let round = 0; round < MAX_ROUNDS; round++) {
312
+ await engine.drain();
313
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
314
+ state = pi?.state ?? "?";
315
+ if (state === "COMPLETED" || state === "TERMINATED") break;
316
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
317
+ assertEquals(open.length, 0, "the default path must never surface the escalate human leaf");
318
+ }
319
+ assertEquals(state, "COMPLETED", "the default (present) path completes without the human leaf");
320
+ assert(doneRan, "the default path routes to the `done` connector leaf");
321
+ } finally {
322
+ await engine.close();
323
+ }
324
+ }
325
+
326
+ // Guarded path (surface missing): the human leaf parks; `done` never runs.
327
+ {
328
+ const engine = await createWasmEngineClient();
329
+ try {
330
+ let doneRan = false;
331
+ await engine.registerWorker("senior:adopt", async () => ({ surface: "missing" }));
332
+ await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => {
333
+ doneRan = true;
334
+ return {};
335
+ });
336
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
337
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
338
+ const key = run.handle.processInstanceKey;
339
+ let parked = "";
340
+ for (let round = 0; round < MAX_ROUNDS; round++) {
341
+ await engine.drain();
342
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
343
+ if (open.length > 0) {
344
+ parked = open[0].elementId ?? "";
345
+ break;
346
+ }
347
+ }
348
+ assert(
349
+ parked.startsWith("delivery-human-task__") && !parked.endsWith("__esc"),
350
+ `the missing outcome must park on the escalate human leaf, saw ${JSON.stringify(parked)}`,
351
+ );
352
+ assert(!doneRan, "the guarded (missing) path must NOT run the `done` leaf");
353
+ } finally {
354
+ await engine.close();
355
+ }
356
+ }
357
+ });
@@ -79,12 +79,46 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
79
79
  assert(wait?.probe && typeof wait.probe === "object", "the wait node carries its ReadinessProbe descriptor");
80
80
 
81
81
  const human = byField((v) => "escalationSlaTimeout" in v);
82
- assertEquals(human, { escalationSlaTimeout: "PT2H", escalationAssignee: "alice" });
82
+ assertEquals(human, {
83
+ escalationSlaTimeout: "PT2H",
84
+ escalationAssignee: "alice",
85
+ // #499: the human node seeds its authored prompt, node identity, and declared emits so the
86
+ // generic user-task form renders "now do X", names the parked node, and labels its emit field.
87
+ prompt: "run the manual OTP publish",
88
+ nodeId: "publish",
89
+ emits: [{ name: "resolvedArtifact", type: "artifact" }],
90
+ });
83
91
 
84
92
  const connector = byField((v) => v.target === "npm:install");
85
93
  assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
86
94
  });
87
95
 
96
+ test("the human node seeds prompt/nodeId/emits; a click-done (no-emit, no-prompt) node seeds empty defaults", async () => {
97
+ // #499: the compiled human user-task's form reads `prompt`/`nodeId`/`emits` from `nodeInputs.<el>`;
98
+ // a discarded prompt is the contextless-form bug. Pin both an emit-declaring node and the degenerate
99
+ // click-done node (no `human` config, no `emits`) so the seed never regresses to null/undefined.
100
+ const graph: DeliveryGraph = {
101
+ nodes: [
102
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
103
+ { id: "ack", kind: "human" },
104
+ ],
105
+ edges: [{ from: "publish.resolvedArtifact", to: "ack" }],
106
+ };
107
+ const p = await prepareOk(graph);
108
+ const humans = Object.values(p.nodeInputs).filter((v) => "escalationSlaTimeout" in v) as Array<Record<string, unknown>>;
109
+ const publish = humans.find((v) => v.nodeId === "publish");
110
+ const ack = humans.find((v) => v.nodeId === "ack");
111
+
112
+ assertEquals(publish?.prompt, "run the manual OTP publish");
113
+ assertEquals(publish?.emits, [{ name: "resolvedArtifact", type: "artifact" }]);
114
+
115
+ // The click-done node carries a defined-but-empty prompt and an empty emits list (never undefined),
116
+ // so the form seeds a blank instruction and hides its emit field rather than seeding null.
117
+ assertEquals(ack?.prompt, "");
118
+ assertEquals(ack?.emits, []);
119
+ assertEquals(ack?.nodeId, "ack");
120
+ });
121
+
88
122
  test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
89
123
  const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
90
124
  (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
- import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
18
+ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
21
 
@@ -65,7 +65,7 @@ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
65
65
  type NodeInput =
66
66
  | { jobType: string; appendPrompt: string; timeout: string }
67
67
  | { gateKey: string; probe: unknown; probeTimeout: string; probePollEvery: string }
68
- | { escalationSlaTimeout: string; escalationAssignee: string | null }
68
+ | { escalationSlaTimeout: string; escalationAssignee: string | null; prompt: string; nodeId: string; emits: DeliveryFact[] }
69
69
  | { target: string; dedupeKey: string | null; payload: Record<string, unknown> | null; timeout: string };
70
70
 
71
71
  /** The result of compiling + preparing a graph for deployment: the content-addressed process id, the
@@ -185,7 +185,17 @@ function buildNodeInput(
185
185
  };
186
186
  }
187
187
  case "human":
188
- return { escalationSlaTimeout: ctx.escalationSlaTimeout, escalationAssignee: ctx.escalationAssignee };
188
+ return {
189
+ escalationSlaTimeout: ctx.escalationSlaTimeout,
190
+ escalationAssignee: ctx.escalationAssignee,
191
+ // Seed the authored instruction, node identity, and declared emits so the human user-task's
192
+ // form can render its "now do X" prompt, name the parked node, and label/hide its emit field
193
+ // (issue #499 — the generic form otherwise renders contextless). `emits` stays the single
194
+ // source of truth: the compiled ioMapping derives the emit label/mode from it in FEEL.
195
+ prompt: node.human?.prompt ?? "",
196
+ nodeId: node.id ?? ctx.element,
197
+ emits: Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [],
198
+ };
189
199
  case "connector":
190
200
  return {
191
201
  target: node.connector.target,
@@ -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.130.0",
3
+ "version": "0.131.1",
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 {
@@ -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
- const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
145
- drill.setAttribute("type", "button");
146
- drill.setAttribute("data-instance", worker.instance);
147
- drill.setAttribute("data-stream", worker.stream);
148
- if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
149
- nameCell.appendChild(drill);
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 sink = xtermSink(terminalHost);
590
- terminal = sink;
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"
@@ -3,6 +3,13 @@
3
3
  "schemaVersion": 18,
4
4
  "type": "default",
5
5
  "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "### Delivery graph — node `{{nodeId}}`",
9
+ "conditional": {
10
+ "hide": "=(nodeId = null) or (nodeId = \"\")"
11
+ }
12
+ },
6
13
  {
7
14
  "type": "textarea",
8
15
  "key": "prompt",
@@ -10,15 +17,32 @@
10
17
  "description": "The scheduled human step this delivery graph is waiting on.",
11
18
  "readonly": true
12
19
  },
20
+ {
21
+ "type": "text",
22
+ "text": "**Emits:** {{emitLabel}} — enter its typed value below (validated against the node's declared fact).",
23
+ "conditional": {
24
+ "hide": "=emitMode != \"typed\""
25
+ }
26
+ },
13
27
  {
14
28
  "type": "textfield",
15
29
  "key": "value",
16
30
  "label": "Emitted value",
17
31
  "description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
32
+ "conditional": {
33
+ "hide": "=emitMode != \"typed\""
34
+ },
18
35
  "validate": {
19
36
  "required": true
20
37
  }
21
38
  },
39
+ {
40
+ "type": "text",
41
+ "text": "_This step emits no typed fact (N/A) — just complete it to unblock its dependents._",
42
+ "conditional": {
43
+ "hide": "=emitMode = \"typed\""
44
+ }
45
+ },
22
46
  {
23
47
  "type": "textarea",
24
48
  "key": "note",
@@ -10,6 +10,10 @@
10
10
  <zeebe:userTask />
11
11
  <zeebe:assignmentDefinition candidateGroups="operators" assignee="=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != &#34;&#34;) then escalationAssignee else null" />
12
12
  <zeebe:ioMapping>
13
+ <zeebe:input source="=if (is defined(prompt)) then prompt else null" target="prompt" />
14
+ <zeebe:input source="=if (is defined(nodeId)) then nodeId else null" target="nodeId" />
15
+ <zeebe:input source="=if (is defined(emits) and count(emits) != 0) then &#34;typed&#34; else &#34;none&#34;" target="emitMode" />
16
+ <zeebe:input source="=if (is defined(emits)) then string join(for _e in emits return _e.name + &#34; (&#34; + _e.type + &#34;)&#34;, &#34;, &#34;) else &#34;&#34;" target="emitLabel" />
13
17
  <zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />
14
18
  <zeebe:output source="=if (is defined(value)) then value else null" target="humanEmitValue" />
15
19
  <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="humanEmitArtifact" />