@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.
- 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/deliveryGraph.test.ts +279 -0
- package/app/deliveryGraph.ts +381 -2
- package/app/deliveryGraphCompiler.test.ts +166 -0
- package/app/deliveryGraphCompiler.ts +279 -52
- package/app/deliveryGraphDeploy.test.ts +148 -0
- package/app/deliveryRunner.test.ts +35 -1
- package/app/deliveryRunner.ts +13 -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
- package/resources/forms/delivery-human-generic.form +24 -0
- package/resources/processes/delivery-human.bpmn +4 -0
|
@@ -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, {
|
|
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;
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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 {
|
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"
|
|
@@ -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)) != "") 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 "typed" else "none"" target="emitMode" />
|
|
16
|
+
<zeebe:input source="=if (is defined(emits)) then string join(for _e in emits return _e.name + " (" + _e.type + ")", ", ") else """ target="emitLabel" />
|
|
13
17
|
<zeebe:output source="="completed"" 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" />
|