@nanobpm/nano-workforce 0.131.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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** seed human-task prompt/context so its form is not contextless ([#502](https://github.com/nanobpm/nano-workforce/issues/502)) ([d2991ea](https://github.com/nanobpm/nano-workforce/commit/d2991ea0ec54e36711c27f58b8901ca85b7179e6)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499) [#499](https://github.com/nanobpm/nano-workforce/issues/499)
6
+
1
7
  ## [0.131.0](https://github.com/nanobpm/nano-workforce/compare/v0.130.0...v0.131.0) (2026-08-23)
2
8
 
3
9
  ### Features
@@ -29,6 +29,17 @@ async function compileFail(graph: unknown) {
29
29
  return r.errors;
30
30
  }
31
31
 
32
+ /** The sub-process element id of the PLANNED human user task in a compiled graph — i.e. the
33
+ * delivery-human-task element that is NOT a bounded node's __esc timeout twin. Returns "" if none. */
34
+ function humanTaskSubEl(bpmn: string): string {
35
+ const parts = bpmn.split('<bpmn:userTask id="delivery-human-task__');
36
+ for (let k = 1; k < parts.length; k++) {
37
+ const id = parts[k].slice(0, parts[k].indexOf('"'));
38
+ if (!id.endsWith("__esc")) return id;
39
+ }
40
+ return "";
41
+ }
42
+
32
43
  // The ADR's motivating case: an agent merges PR #B, a `pr` wait node watches it merge and emits
33
44
  // `mergedSha`, a human does the manual OTP publish emitting `resolvedArtifact`, and a connector
34
45
  // consumes the published artifact.
@@ -114,6 +125,42 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
114
125
  assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
115
126
  });
116
127
 
128
+ test("#499 human context: the human user-task seeds prompt/nodeId/emit context so its generic form is not contextless", async () => {
129
+ const r = await compileOk(RELEASE_RUNBOOK);
130
+ // The human node's subProcess ioMapping must thread the authored prompt + node identity + emit
131
+ // context from `nodeInputs.<el>` onto the user task (the form reads them). A dropped prompt input is
132
+ // exactly the contextless-form bug (#499). Locate the PLANNED human task (not an `__esc` twin).
133
+ const subEl = humanTaskSubEl(r.bpmn);
134
+ assert(subEl !== "", "the graph inlines a planned human user task");
135
+ assert(r.bpmn.includes(`source="=nodeInputs.${subEl}.prompt" target="prompt"`), "the human task seeds its authored prompt");
136
+ assert(r.bpmn.includes(`source="=nodeInputs.${subEl}.nodeId" target="nodeId"`), "the human task seeds its node identity");
137
+ // The emit label/mode are DERIVED in FEEL from the seeded emits list (single source of truth) — a
138
+ // single-quoted attribute so the literal quotes survive the engine deploy path.
139
+ assert(
140
+ r.bpmn.includes(`source='=if count(nodeInputs.${subEl}.emits) = 0 then "none" else "typed"' target="emitMode"`),
141
+ "emitMode is derived from the emits count",
142
+ );
143
+ assert(
144
+ r.bpmn.includes(`for _e in nodeInputs.${subEl}.emits return _e.name`) && r.bpmn.includes('target="emitLabel"'),
145
+ "emitLabel is derived from the emits list",
146
+ );
147
+ });
148
+
149
+ test("#499 escalation context: an agent-node timeout escalation seeds a context line naming the node, its job type, and the elapsed SLA", async () => {
150
+ const r = await compileOk(RELEASE_RUNBOOK);
151
+ // The `__esc` timeout-escalation user task previously carried NO ioMapping — a blank form that never
152
+ // said a timeout occurred, on which node. It must now seed a `prompt` context line from a compile-time
153
+ // literal (node id + job type) concatenated with the runtime elapsed SLA (`nodeTimeout`).
154
+ const start = r.bpmn.indexOf('<bpmn:userTask id="delivery-human-task__n1__esc"');
155
+ assert(start !== -1, "a bounded node inlines an escalation user task");
156
+ const escBlock = r.bpmn.slice(start, r.bpmn.indexOf("</bpmn:userTask>", start));
157
+ assert(escBlock.includes('target="prompt"'), "the escalation task seeds a prompt context line");
158
+ assert(escBlock.includes("Node open-b (senior:feature) exceeded its SLA ("), "the context names the node and its job type");
159
+ assert(escBlock.includes("string(nodeTimeout)"), "the context reports the elapsed SLA at runtime");
160
+ assert(escBlock.includes('="none"') && escBlock.includes('target="emitMode"'), "the escalation labels its emit field N/A so the generic form hides the inert value input");
161
+ });
162
+
163
+
117
164
  test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
118
165
  const errors = await compileFail({
119
166
  nodes: [{ id: "x", kind: "deploy", deploy: { target: "prod" } }],
@@ -823,6 +823,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
823
823
  case "human":
824
824
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
825
825
  inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
826
+ // Seed the authored instruction + node identity + emit context so the generic human form renders
827
+ // "now do X", names the parked node, and labels/hides its emit field (issue #499). `emits` is the
828
+ // single source of truth; the emit label/mode are derived from it in FEEL here (no duplicate seed).
829
+ inputs.push({ source: cfg("prompt"), target: "prompt" });
830
+ inputs.push({ source: cfg("nodeId"), target: "nodeId" });
831
+ inputs.push({ source: `=if count(${cfg("emits").slice(1)}) = 0 then "none" else "typed"`, target: "emitMode" });
832
+ inputs.push({
833
+ source: `=string join(for _e in ${cfg("emits").slice(1)} return _e.name + " (" + _e.type + ")", ", ")`,
834
+ target: "emitLabel",
835
+ });
826
836
  break;
827
837
  case "connector":
828
838
  inputs.push({ source: cfg("target"), target: "target" });
@@ -868,9 +878,9 @@ function innerBodyLines(w: NodeWiring): string[] {
868
878
  const node = w.node;
869
879
  switch (node.kind) {
870
880
  case "agent":
871
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), []);
881
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType);
872
882
  case "connector":
873
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, []);
883
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
874
884
  case "wait":
875
885
  return waitBodyLines(el, node.id);
876
886
  case "human":
@@ -882,8 +892,15 @@ function innerBodyLines(w: NodeWiring): string[] {
882
892
 
883
893
  /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
884
894
  * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
885
- * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines. */
886
- function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskProps: readonly string[]): string[] {
895
+ * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
896
+ * names the stalled work (job type / connector target) for the escalation task's context line (#499). */
897
+ function serviceBodyLines(
898
+ el: string,
899
+ nodeId: string,
900
+ taskDefAttr: string,
901
+ taskProps: readonly string[],
902
+ descriptor: string,
903
+ ): string[] {
887
904
  const esc = escalationTaskElement(el);
888
905
  const taskExt =
889
906
  taskProps.length > 0
@@ -911,7 +928,18 @@ function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskP
911
928
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
912
929
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
913
930
  " </bpmn:boundaryEvent>",
914
- ...escalationTaskLines(esc, nodeId, [`${el}_i2`], `${el}_i3`),
931
+ ...escalationTaskLines(
932
+ esc,
933
+ nodeId,
934
+ [`${el}_i2`],
935
+ `${el}_i3`,
936
+ escalationContextFeel(
937
+ nodeId,
938
+ descriptor,
939
+ "nodeTimeout",
940
+ "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
941
+ ),
942
+ ),
915
943
  ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
916
944
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
917
945
  flow(`${el}_i1`, `${el}_task`, `${el}_end`),
@@ -991,7 +1019,13 @@ function waitBodyLines(el: string, nodeId: string): string[] {
991
1019
  ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
992
1020
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
993
1021
  " </bpmn:exclusiveGateway>",
994
- ...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
1022
+ ...escalationTaskLines(
1023
+ esc,
1024
+ nodeId,
1025
+ [`${el}_i4`],
1026
+ `${el}_i5`,
1027
+ escalationContextFeel(nodeId, "readiness gate", "probeTimeout", " before its ReadinessProbe went green — decide how to proceed."),
1028
+ ),
995
1029
  ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
996
1030
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
997
1031
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
@@ -1047,14 +1081,29 @@ function humanBodyLines(el: string, nodeId: string): string[] {
1047
1081
  }
1048
1082
 
1049
1083
  /** A bounded node's escalation user task — a human-completable stop (`isDeliveryHumanElement`
1050
- * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. */
1051
- function escalationTaskLines(esc: string, nodeId: string, incoming: readonly string[], outgoing: string): string[] {
1084
+ * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
1085
+ * a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
1086
+ * (issue #499) — e.g. "Node n1 (senior:feature) exceeded its SLA (PT30M); …" — so the operator can see
1087
+ * WHICH node timed out and that in-flight work may already exist, instead of a blank form. The emit
1088
+ * field is labelled "none" so the generic form hides its (inert on an escalation) typed-value input. */
1089
+ function escalationTaskLines(
1090
+ esc: string,
1091
+ nodeId: string,
1092
+ incoming: readonly string[],
1093
+ outgoing: string,
1094
+ contextFeel: string,
1095
+ ): string[] {
1052
1096
  return [
1053
1097
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
1054
1098
  " <bpmn:extensionElements>",
1055
1099
  ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
1056
1100
  " <zeebe:userTask />",
1057
1101
  ' <zeebe:assignmentDefinition candidateGroups="operators" />',
1102
+ " <zeebe:ioMapping>",
1103
+ ` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
1104
+ ` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
1105
+ ` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
1106
+ " </zeebe:ioMapping>",
1058
1107
  " </bpmn:extensionElements>",
1059
1108
  ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
1060
1109
  ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
@@ -1062,6 +1111,15 @@ function escalationTaskLines(esc: string, nodeId: string, incoming: readonly str
1062
1111
  ];
1063
1112
  }
1064
1113
 
1114
+ /** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).
1115
+ * The node id + descriptor (job type / connector target / "readiness gate") are baked as compile-time
1116
+ * literals; the elapsed SLA is read from the node body's runtime `timeoutVar` (`nodeTimeout` for a
1117
+ * bounded service node, `probeTimeout` for a `wait` gate). `tail` closes the sentence per kind. */
1118
+ function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: string, tail: string): string {
1119
+ const head = feelStr(`Node ${nodeId} (${descriptor}) exceeded its SLA (`);
1120
+ return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
1121
+ }
1122
+
1065
1123
  /** A plain `<bpmn:sequenceFlow>` (6-space indented). */
1066
1124
  function flow(id: string, source: string, target: string): string {
1067
1125
  return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.131.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",
@@ -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" />