@nanobpm/nano-workforce 0.137.0 → 0.138.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 +12 -0
- package/app/deliveryGraphCompiler.test.ts +93 -0
- package/app/deliveryGraphCompiler.ts +82 -8
- package/app/deliveryGraphTextIngress.ts +94 -0
- package/app/readiness.test.ts +63 -0
- package/app/readiness.ts +57 -4
- package/openapi.yaml +73 -16
- package/operations/previewDeliveryGraph.test.ts +15 -13
- package/operations/previewDeliveryGraph.ts +24 -82
- package/operations/stageDeliveryGraph.test.ts +106 -0
- package/operations/stageDeliveryGraph.ts +53 -0
- package/package.json +5 -5
- package/pages/delivery-graphs/delivery-graphs.css +47 -0
- package/pages/delivery-graphs/embed.html +1 -1
- package/pages/delivery-graphs/mount.js +110 -87
- package/pages/delivery-graphs/standalone.html +1 -1
- package/resources/processes/readiness-gate.bpmn +1 -0
- package/test/delivery-graphs-embed.test.ts +47 -35
- package/workers/readiness-probe/worker.test.ts +56 -2
- package/workers/readiness-probe/worker.ts +26 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.138.1](https://github.com/nanobpm/nano-workforce/compare/v0.138.0...v0.138.1) (2026-08-24)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** self-diagnosing + resumable capability wait-gate escalation ([#514](https://github.com/nanobpm/nano-workforce/issues/514)) ([#515](https://github.com/nanobpm/nano-workforce/issues/515)) ([b13ae50](https://github.com/nanobpm/nano-workforce/commit/b13ae50bf9638a5eabd12342e9c3d1ba7d70eaab)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499)
|
|
6
|
+
|
|
7
|
+
## [0.138.0](https://github.com/nanobpm/nano-workforce/compare/v0.137.0...v0.138.0) (2026-08-24)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-graphs:** split preview from stage + collapsible compose + fix example ([#517](https://github.com/nanobpm/nano-workforce/issues/517)) ([abb4a10](https://github.com/nanobpm/nano-workforce/commit/abb4a10e9a8f7f53be40488d9991a3660a9f7518)), closes [#516](https://github.com/nanobpm/nano-workforce/issues/516) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#516](https://github.com/nanobpm/nano-workforce/issues/516)
|
|
12
|
+
|
|
1
13
|
## [0.137.0](https://github.com/nanobpm/nano-workforce/compare/v0.136.0...v0.137.0) (2026-08-24)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -160,6 +160,99 @@ test("#499 escalation context: an agent-node timeout escalation seeds a context
|
|
|
160
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
161
|
});
|
|
162
162
|
|
|
163
|
+
// The #514 motivating case: a `capability` wait gate that emits a version, escalating on a
|
|
164
|
+
// false-negative. Its escalation must be self-diagnosing (Defect A) AND resumable with its emit
|
|
165
|
+
// (Defect B). Two emit shapes (`version` → `detail`, `artifact` → `resolvedArtifact`) exercise the
|
|
166
|
+
// per-emit-type mapping.
|
|
167
|
+
const CAP_GATE = {
|
|
168
|
+
name: "capability gate",
|
|
169
|
+
nodes: [
|
|
170
|
+
{ id: "gv", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship the rollup" } },
|
|
171
|
+
{
|
|
172
|
+
id: "n2",
|
|
173
|
+
kind: "wait",
|
|
174
|
+
wait: {
|
|
175
|
+
kind: "capability",
|
|
176
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
177
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#468" },
|
|
178
|
+
onTimeout: "escalate",
|
|
179
|
+
},
|
|
180
|
+
emits: [{ name: "publishedVersion", type: "version" }],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "n3",
|
|
184
|
+
kind: "wait",
|
|
185
|
+
wait: {
|
|
186
|
+
kind: "capability",
|
|
187
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
188
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#469" },
|
|
189
|
+
onTimeout: "escalate",
|
|
190
|
+
},
|
|
191
|
+
emits: [{ name: "artifactRef", type: "artifact" }],
|
|
192
|
+
},
|
|
193
|
+
{ id: "sink", kind: "connector", connector: { target: "npm:install", dedupeKey: "c1" } },
|
|
194
|
+
],
|
|
195
|
+
edges: [
|
|
196
|
+
{ from: "gv", to: "n2" },
|
|
197
|
+
{ from: "n2.publishedVersion", to: "n3" },
|
|
198
|
+
{ from: "n3.artifactRef", to: "sink" },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** The subProcess element id the compiler assigned to a node (elements are positional `n<k>`, not the
|
|
203
|
+
* node id). Located via the subProcess `name="<kind>: <nodeId>"`. */
|
|
204
|
+
function elementForNode(bpmn: string, nodeId: string): string {
|
|
205
|
+
const m = bpmn.match(new RegExp(`<bpmn:subProcess id="([^"]+)" name="[^"]*: ${nodeId}"`));
|
|
206
|
+
assert(m, `a subProcess for node ${nodeId} exists`);
|
|
207
|
+
return m![1];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Slice a compiled BPMN to a node's escalation user task body. */
|
|
211
|
+
function escBlockForNode(bpmn: string, nodeId: string): string {
|
|
212
|
+
const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__esc`;
|
|
213
|
+
const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
|
|
214
|
+
assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
|
|
215
|
+
return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last detail, target/match, and observed releases so it is self-diagnosing", async () => {
|
|
219
|
+
const r = await compileOk(CAP_GATE);
|
|
220
|
+
const esc = escBlockForNode(r.bpmn, "n2");
|
|
221
|
+
// The read-only prompt context now folds in the RUNTIME last probe detail + observed candidate summary.
|
|
222
|
+
assert(esc.includes('target="prompt"'), "the escalation seeds a prompt context line");
|
|
223
|
+
assert(esc.includes("Last probe: ") && esc.includes("if (is defined(detail)) then string(detail)"), "the prompt folds in the last probe detail");
|
|
224
|
+
assert(esc.includes("Observed: ") && esc.includes("if (is defined(observed)) then string(observed)"), "the prompt folds in the observed candidate summary");
|
|
225
|
+
// Discrete diagnostic task variables the form/agent can bind directly.
|
|
226
|
+
assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
|
|
227
|
+
assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
|
|
228
|
+
assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
|
|
229
|
+
assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("#514 Defect B: a resumed wait-node escalation maps the operator-supplied value onto the node's emit source (version→detail, artifact→resolvedArtifact)", async () => {
|
|
233
|
+
const r = await compileOk(CAP_GATE);
|
|
234
|
+
// Red before the fix: the wait escalation forced emitMode="none" (hiding the value field) and carried
|
|
235
|
+
// NO output mapping, so a resume published `<el>_<fact> = null`, starving the downstream consumer.
|
|
236
|
+
const escV = escBlockForNode(r.bpmn, "n2");
|
|
237
|
+
// A `version` emit is sourced from `detail` — the operator's captured `value` must be mapped there.
|
|
238
|
+
assert(escV.includes('="typed"') && escV.includes('target="emitMode"'), "a wait node with emits PRESENTS its value field on escalation, not 'none'");
|
|
239
|
+
assert(escV.includes("publishedVersion (version)") && escV.includes('target="emitLabel"'), "the emit label names the awaited fact");
|
|
240
|
+
assert(/source="=if \(is defined\(value\)\) then value else null" target="detail"/.test(escV), "the operator's value is mapped onto the version emit's source var (detail)");
|
|
241
|
+
|
|
242
|
+
const escA = escBlockForNode(r.bpmn, "n3");
|
|
243
|
+
// An `artifact` emit is ALSO sourced from the generic form's single `value` field (the form has no
|
|
244
|
+
// `resolvedArtifact` field), mapped onto the artifact emit's source var (resolvedArtifact) — so an
|
|
245
|
+
// artifact wait-node escalation is actually resumable via the UI.
|
|
246
|
+
assert(/source="=if \(is defined\(value\)\) then value else null" target="resolvedArtifact"/.test(escA), "the operator's value is mapped onto the artifact emit's source var (resolvedArtifact)");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#514 Defect B: a service-node escalation (agent) stays inert — no emit field, no resume output mapping (only wait resumes)", async () => {
|
|
250
|
+
const r = await compileOk(CAP_GATE);
|
|
251
|
+
const esc = escBlockForNode(r.bpmn, "gv");
|
|
252
|
+
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "an agent-node escalation keeps its emit field hidden");
|
|
253
|
+
assert(!esc.includes("<bpmn:output") && !esc.includes("<zeebe:output"), "an agent-node escalation carries no emit-source output mapping");
|
|
254
|
+
});
|
|
255
|
+
|
|
163
256
|
|
|
164
257
|
test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
|
|
165
258
|
const errors = await compileFail({
|
|
@@ -882,7 +882,7 @@ function innerBodyLines(w: NodeWiring): string[] {
|
|
|
882
882
|
case "connector":
|
|
883
883
|
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
|
|
884
884
|
case "wait":
|
|
885
|
-
return waitBodyLines(el, node
|
|
885
|
+
return waitBodyLines(el, node);
|
|
886
886
|
case "human":
|
|
887
887
|
return humanBodyLines(el, node.id);
|
|
888
888
|
default:
|
|
@@ -951,8 +951,19 @@ function serviceBodyLines(
|
|
|
951
951
|
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
952
952
|
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
953
953
|
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
954
|
-
function waitBodyLines(el: string,
|
|
954
|
+
function waitBodyLines(el: string, node: DeliveryNode): string[] {
|
|
955
|
+
const nodeId = node.id;
|
|
955
956
|
const esc = escalationTaskElement(el);
|
|
957
|
+
const emits = normaliseEmits(node);
|
|
958
|
+
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
959
|
+
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
960
|
+
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
961
|
+
const diagnosticInputs = [
|
|
962
|
+
{ source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
|
|
963
|
+
{ source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
|
|
964
|
+
{ source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
|
|
965
|
+
{ source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
|
|
966
|
+
];
|
|
956
967
|
return [
|
|
957
968
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
958
969
|
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
|
|
@@ -962,6 +973,7 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
962
973
|
' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
|
|
963
974
|
' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
|
|
964
975
|
' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
|
|
976
|
+
' <zeebe:output source="=if (is defined(observed)) then observed else null" target="observed" />',
|
|
965
977
|
" </zeebe:ioMapping>",
|
|
966
978
|
" </bpmn:extensionElements>",
|
|
967
979
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
@@ -1024,7 +1036,8 @@ function waitBodyLines(el: string, nodeId: string): string[] {
|
|
|
1024
1036
|
nodeId,
|
|
1025
1037
|
[`${el}_i4`],
|
|
1026
1038
|
`${el}_i5`,
|
|
1027
|
-
|
|
1039
|
+
waitEscalationContextFeel(nodeId),
|
|
1040
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs },
|
|
1028
1041
|
),
|
|
1029
1042
|
` <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>`,
|
|
1030
1043
|
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
@@ -1084,15 +1097,60 @@ function humanBodyLines(el: string, nodeId: string): string[] {
|
|
|
1084
1097
|
* convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. `contextFeel` is
|
|
1085
1098
|
* a FEEL expression yielding the context line seeded onto the generic form's read-only prompt field
|
|
1086
1099
|
* (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.
|
|
1088
|
-
*
|
|
1100
|
+
* WHICH node timed out and that in-flight work may already exist, instead of a blank form.
|
|
1101
|
+
*
|
|
1102
|
+
* `opts.resume` turns an inert escalation into a RESUMABLE one (issue #514 Defect B): when the parked
|
|
1103
|
+
* `wait` node declares emits, the form must both PRESENT its typed-value field (so `emitMode`/
|
|
1104
|
+
* `emitLabel` are derived from those emits, not forced to "none") and, on completion, MAP the
|
|
1105
|
+
* operator-supplied value onto the node's emit-source variable (`detail` for scalar/version,
|
|
1106
|
+
* `resolvedArtifact` for artifact, `mergedSha` for a merge oid — {@link factSourceVar}). Without that
|
|
1107
|
+
* mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
|
|
1108
|
+
* `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
|
|
1109
|
+
* operator can see WHY the gate escalated (its last probe detail + observed candidate releases). */
|
|
1089
1110
|
function escalationTaskLines(
|
|
1090
1111
|
esc: string,
|
|
1091
1112
|
nodeId: string,
|
|
1092
1113
|
incoming: readonly string[],
|
|
1093
1114
|
outgoing: string,
|
|
1094
1115
|
contextFeel: string,
|
|
1116
|
+
opts?: {
|
|
1117
|
+
resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
|
|
1118
|
+
diagnosticInputs?: readonly { source: string; target: string }[];
|
|
1119
|
+
},
|
|
1095
1120
|
): string[] {
|
|
1121
|
+
const emits = opts?.resume?.emits ?? [];
|
|
1122
|
+
const emitMode = emits.length > 0 ? "typed" : "none";
|
|
1123
|
+
const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
|
|
1124
|
+
const inputs: string[] = [
|
|
1125
|
+
` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
|
|
1126
|
+
` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
|
|
1127
|
+
` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
|
|
1128
|
+
];
|
|
1129
|
+
if (emits.length > 0) {
|
|
1130
|
+
inputs.push(` <zeebe:input ${attr("source", `=${feelStr(emitLabel)}`)} target="emitLabel" />`);
|
|
1131
|
+
}
|
|
1132
|
+
for (const di of opts?.diagnosticInputs ?? []) {
|
|
1133
|
+
inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
|
|
1134
|
+
}
|
|
1135
|
+
// Defect B: map the operator's captured typed value onto the node's emit-source var, so the
|
|
1136
|
+
// subProcess output ioMapping publishes the SAME `<el>_<fact>` shape a normally-completing node does.
|
|
1137
|
+
const outputs: string[] = [];
|
|
1138
|
+
if (opts?.resume) {
|
|
1139
|
+
const seen = new Set<string>();
|
|
1140
|
+
for (const fact of emits) {
|
|
1141
|
+
const target = factSourceVar(opts.resume.kind, fact);
|
|
1142
|
+
if (seen.has(target)) continue;
|
|
1143
|
+
seen.add(target);
|
|
1144
|
+
// The generic escalation form (`GENERIC_HUMAN_FORM`) captures the operator's answer in a single
|
|
1145
|
+
// `value` field — it has NO `resolvedArtifact` field — so every emit type resumes from `value`,
|
|
1146
|
+
// mapped onto that fact's emit-source var (artifact→resolvedArtifact, version→detail, …). Sourcing
|
|
1147
|
+
// an artifact from a `resolvedArtifact` form field the form never sets would publish null and make
|
|
1148
|
+
// an artifact wait-node escalation non-resumable via the UI.
|
|
1149
|
+
outputs.push(
|
|
1150
|
+
` <zeebe:output ${attr("source", `=if (is defined(value)) then value else null`)} target="${target}" />`,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1096
1154
|
return [
|
|
1097
1155
|
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
|
|
1098
1156
|
" <bpmn:extensionElements>",
|
|
@@ -1100,9 +1158,8 @@ function escalationTaskLines(
|
|
|
1100
1158
|
" <zeebe:userTask />",
|
|
1101
1159
|
' <zeebe:assignmentDefinition candidateGroups="operators" />',
|
|
1102
1160
|
" <zeebe:ioMapping>",
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
` <zeebe:input ${attr("source", '="none"')} target="emitMode" />`,
|
|
1161
|
+
...inputs,
|
|
1162
|
+
...outputs,
|
|
1106
1163
|
" </zeebe:ioMapping>",
|
|
1107
1164
|
" </bpmn:extensionElements>",
|
|
1108
1165
|
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
@@ -1120,6 +1177,23 @@ function escalationContextFeel(nodeId: string, descriptor: string, timeoutVar: s
|
|
|
1120
1177
|
return `=${head} + string(${timeoutVar}) + ${feelStr(`)${tail}`)}`;
|
|
1121
1178
|
}
|
|
1122
1179
|
|
|
1180
|
+
/** The escalation context line for a `wait` gate (issue #514 Defect A). Extends the base #499 line with
|
|
1181
|
+
* the probe's RUNTIME last `detail` and its observed-candidate summary (`observed`), so a human/agent
|
|
1182
|
+
* reading the (read-only) prompt can immediately tell a genuine "not published yet" from a transient
|
|
1183
|
+
* false-negative — without hunting for the internal variables. Both are folded in defensively (an
|
|
1184
|
+
* as-yet-unset var renders "—", never a FEEL error). */
|
|
1185
|
+
function waitEscalationContextFeel(nodeId: string): string {
|
|
1186
|
+
const base = escalationContextFeel(
|
|
1187
|
+
nodeId,
|
|
1188
|
+
"readiness gate",
|
|
1189
|
+
"probeTimeout",
|
|
1190
|
+
" before its ReadinessProbe went green — decide how to proceed.",
|
|
1191
|
+
);
|
|
1192
|
+
const lastProbe = `(if (is defined(detail)) then string(detail) else "—")`;
|
|
1193
|
+
const observed = `(if (is defined(observed)) then string(observed) else "—")`;
|
|
1194
|
+
return `${base} + " Last probe: " + ${lastProbe} + ". Observed: " + ${observed} + "."`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1123
1197
|
/** A plain `<bpmn:sequenceFlow>` (6-space indented). */
|
|
1124
1198
|
function flow(id: string, source: string, target: string): string {
|
|
1125
1199
|
return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// app/deliveryGraphTextIngress.ts — the shared PARSE → COMPILE → PROJECT pipeline behind the two
|
|
2
|
+
// human-facing delivery-graph text-ingress doors (issue #516):
|
|
3
|
+
// • `previewDeliveryGraph` — PURE preview: compile + project, NO staging.
|
|
4
|
+
// • `stageDeliveryGraph` — compile + project + STAGE a proposal for operator dispatch.
|
|
5
|
+
//
|
|
6
|
+
// Splitting preview from staging means both doors run the IDENTICAL parse+compile+project step and
|
|
7
|
+
// differ ONLY in whether they persist a staged proposal. That step therefore lives here ONCE
|
|
8
|
+
// (derivation over duplication) rather than being copied per door — the previous single door inlined
|
|
9
|
+
// it, and forking it would have created two drift-prone compile paths. Neither door dispatches; the
|
|
10
|
+
// #460 boundary (dispatch is an operator action on a staged proposal) is untouched.
|
|
11
|
+
|
|
12
|
+
import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
|
|
13
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
14
|
+
import { proposalReviewUrl } from "./deliveryGraphProposals.ts";
|
|
15
|
+
import { parseDeliveryGraphText } from "./deliveryGraphText.ts";
|
|
16
|
+
import { deliveryGraphDigest } from "./deliveryRunner.ts";
|
|
17
|
+
|
|
18
|
+
type CompileResult = Awaited<ReturnType<typeof compileDeliveryGraph>>;
|
|
19
|
+
type CompiledOk = Extract<CompileResult, { ok: true }>;
|
|
20
|
+
type CompileErrors = Extract<CompileResult, { ok: false }>["errors"];
|
|
21
|
+
|
|
22
|
+
/** A parse/validation failure, already shaped as the door's 400 response. */
|
|
23
|
+
export interface TextIngressFailure {
|
|
24
|
+
ok: false;
|
|
25
|
+
status: 400;
|
|
26
|
+
body: { ok: false; error: string; errors?: CompileErrors };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A well-formed, compiled graph ready to project (and, for the stage door, to persist). */
|
|
30
|
+
export interface TextIngressOk {
|
|
31
|
+
ok: true;
|
|
32
|
+
graph: unknown;
|
|
33
|
+
compiled: CompiledOk;
|
|
34
|
+
digest: string;
|
|
35
|
+
name: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TextIngressResult = TextIngressOk | TextIngressFailure;
|
|
39
|
+
|
|
40
|
+
/** Parse a UI JSON-paste body (`{ graphJson }`), then run the SAME pure compiler the agent door uses.
|
|
41
|
+
* A blank/invalid paste or a graph that fails validation is returned as a ready-to-send 400; success
|
|
42
|
+
* carries the compiled graph plus its content `digest` and human `name`. Never throws / never a 500. */
|
|
43
|
+
export async function parseAndCompileText(body: unknown): Promise<TextIngressResult> {
|
|
44
|
+
const parsed = parseDeliveryGraphText(body);
|
|
45
|
+
if (!parsed.ok) {
|
|
46
|
+
return { ok: false, status: 400, body: { ok: false, error: parsed.error } };
|
|
47
|
+
}
|
|
48
|
+
const compiled = await compileDeliveryGraph(parsed.graph);
|
|
49
|
+
if (!compiled.ok) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
status: 400,
|
|
53
|
+
body: {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `graph failed validation: ${compiled.errors.length} error(s)`,
|
|
56
|
+
errors: compiled.errors,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const digest = deliveryGraphDigest(compiled.bpmn);
|
|
61
|
+
const name =
|
|
62
|
+
typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
|
|
63
|
+
? compiled.resolved.name.trim()
|
|
64
|
+
: null;
|
|
65
|
+
return { ok: true, graph: parsed.graph, compiled, digest, name };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Project a compiled graph into the shared `DeliveryGraphTextResult` 200 body both doors return: the
|
|
69
|
+
* `digest`, node/human/side-effect counts, the mermaid `diagram`, and the full human-stop / side-effect
|
|
70
|
+
* detail the Delivery Graphs page renders (#441). `staged` records whether a proposal was persisted;
|
|
71
|
+
* `includeBpmn` attaches the compiled BPMN (with DI) so the PURE preview door can drive the host
|
|
72
|
+
* explorer's DI preview WITHOUT staging (the stage door omits it — the staged grid recompiles by
|
|
73
|
+
* digest). */
|
|
74
|
+
export function buildTextPreviewBody(
|
|
75
|
+
ok: TextIngressOk,
|
|
76
|
+
opts: { staged: boolean; includeBpmn?: boolean },
|
|
77
|
+
): DeliveryGraphTextResult {
|
|
78
|
+
const { compiled, digest, name } = ok;
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
staged: opts.staged,
|
|
82
|
+
digest,
|
|
83
|
+
reviewUrl: proposalReviewUrl(digest),
|
|
84
|
+
...(name !== null ? { title: name } : {}),
|
|
85
|
+
sideEffecting: compiled.sideEffects.length > 0,
|
|
86
|
+
nodeCount: compiled.resolved.nodes.length,
|
|
87
|
+
humanNodeCount: compiled.humanNodes.length,
|
|
88
|
+
sideEffectCount: compiled.sideEffects.length,
|
|
89
|
+
diagram: compiled.diagram,
|
|
90
|
+
humanNodes: compiled.humanNodes,
|
|
91
|
+
sideEffects: compiled.sideEffects,
|
|
92
|
+
...(opts.includeBpmn ? { bpmn: compiled.bpmn } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/app/readiness.test.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
normalizePoll,
|
|
29
29
|
parseProbe,
|
|
30
30
|
parsePrTarget,
|
|
31
|
+
summariseCapabilityCandidates,
|
|
31
32
|
parsePrView,
|
|
32
33
|
parseReleases,
|
|
33
34
|
parseReleasesTarget,
|
|
@@ -214,6 +215,68 @@ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-id
|
|
|
214
215
|
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
|
|
215
216
|
});
|
|
216
217
|
|
|
218
|
+
// ── #514 Defect A: the capability gate is self-diagnosing on escalation ──────────────────────────
|
|
219
|
+
|
|
220
|
+
test("#514 Defect A summariseCapabilityCandidates: lists candidate releases (newest first) and whether each references the ref", () => {
|
|
221
|
+
const summary = summariseCapabilityCandidates(capMatch, [
|
|
222
|
+
rel("@nanobpm/urban@0.9.0", [200]),
|
|
223
|
+
rel("@nanobpm/urban@0.82.0", [274]),
|
|
224
|
+
rel("@nanobpm/other@1.0.0", [274]), // wrong package — excluded from the per-package summary
|
|
225
|
+
]);
|
|
226
|
+
assertStringIncludes(summary, "2 @nanobpm/urban release(s) observed");
|
|
227
|
+
assertStringIncludes(summary, "1 referencing #274");
|
|
228
|
+
// Newest candidate first, and the ref-flag per candidate.
|
|
229
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.82.0 refs #274");
|
|
230
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.9.0 no #274");
|
|
231
|
+
assert(!summary.includes("@nanobpm/other"), "a sibling package never leaks into the per-package summary");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("#514 Defect A summariseCapabilityCandidates: no matching releases yields a plain observed note (never throws)", () => {
|
|
235
|
+
assertEquals(summariseCapabilityCandidates(capMatch, []), "no @nanobpm/urban releases observed");
|
|
236
|
+
assertEquals(summariseCapabilityCandidates(capMatch, [rel("@nanobpm/other@1.0.0", [274])]), "no @nanobpm/urban releases observed");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("#514 Defect A summariseCapabilityCandidates: an unparseable capabilityRef is labelled distinctly and omits the meaningless per-candidate refs/no flags", () => {
|
|
240
|
+
const badRef = { ...capMatch, capabilityRef: "not-a-number" };
|
|
241
|
+
const summary = summariseCapabilityCandidates(badRef, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
242
|
+
assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (unparseable ref)");
|
|
243
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.82.0");
|
|
244
|
+
assert(!summary.includes("(no ref configured)"), "a configured-but-unparseable ref is NOT reported as missing");
|
|
245
|
+
assert(!summary.includes("no #") && !summary.includes("refs #"), "no meaningless per-candidate ref flags when there is no parseable number");
|
|
246
|
+
assert(!summary.includes("referencing"), "no aggregate referencing count when there is no parseable number");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#514 Defect A summariseCapabilityCandidates: a genuinely absent ref is labelled '(no ref configured)'", () => {
|
|
250
|
+
const noRef = { ...capMatch, capabilityRef: undefined };
|
|
251
|
+
const summary = summariseCapabilityCandidates(noRef, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
252
|
+
assertStringIncludes(summary, "1 @nanobpm/urban release(s) observed (no ref configured)");
|
|
253
|
+
assert(!summary.includes("unparseable"), "a missing ref is not reported as unparseable");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("#514 Defect A summariseCapabilityCandidates: caps the newest 8 candidates so a >100-release repo cannot bloat the form/log", () => {
|
|
257
|
+
const many: GithubRelease[] = [];
|
|
258
|
+
for (let i = 1; i <= 20; i++) many.push(rel(`@nanobpm/urban@0.${i}.0`, [200]));
|
|
259
|
+
const summary = summariseCapabilityCandidates(capMatch, many);
|
|
260
|
+
assertStringIncludes(summary, "20 @nanobpm/urban release(s) observed");
|
|
261
|
+
assertStringIncludes(summary, "(+12 more)");
|
|
262
|
+
assertStringIncludes(summary, "@nanobpm/urban@0.20.0 no #274"); // newest is shown
|
|
263
|
+
assert(!summary.includes("@nanobpm/urban@0.1.0 "), "the oldest is trimmed by the cap");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("#514 Defect A matchCapability surfaces `observed` on the not-ready path — a false-negative is diagnosable, not contextless", () => {
|
|
267
|
+
// Red before the fix: matchCapability returned only {ready, detail} with no observed-release context,
|
|
268
|
+
// so an escalation could not tell a genuine 'not published yet' from a transient false-negative.
|
|
269
|
+
const notReady = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [200])]);
|
|
270
|
+
assert(!notReady.ready);
|
|
271
|
+
assertStringIncludes(notReady.observed ?? "", "@nanobpm/urban@0.82.0 no #274");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("#514 Defect A matchCapability surfaces `observed` on the ready path too (the resolving release shows as referencing the ref)", () => {
|
|
275
|
+
const ready = matchCapability(capMatch, [rel("@nanobpm/urban@0.82.0", [274])]);
|
|
276
|
+
assert(ready.ready);
|
|
277
|
+
assertStringIncludes(ready.observed ?? "", "@nanobpm/urban@0.82.0 refs #274");
|
|
278
|
+
});
|
|
279
|
+
|
|
217
280
|
test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
|
|
218
281
|
assert(cmpVersion("0.9.0", "0.54.0") < 0);
|
|
219
282
|
assert(cmpVersion("0.54.0", "0.60.0") < 0);
|
package/app/readiness.ts
CHANGED
|
@@ -120,6 +120,13 @@ export interface ProbeResult {
|
|
|
120
120
|
readonly ready: boolean;
|
|
121
121
|
readonly detail: string;
|
|
122
122
|
readonly bind?: Record<string, string>;
|
|
123
|
+
/** An OPTIONAL compact, human-readable summary of what the probe actually OBSERVED at poll time
|
|
124
|
+
* (issue #514 Defect A). Diagnostic-only — it is NEVER part of the gate contract, it does not gate
|
|
125
|
+
* readiness, and it carries no secret material (provenance is public). The `capability` kind
|
|
126
|
+
* populates it with {@link summariseCapabilityCandidates} so an ESCALATION (false-negative wait)
|
|
127
|
+
* surfaces the candidate releases the matcher saw and whether each referenced the ref, letting a
|
|
128
|
+
* human/agent tell a genuine "not published yet" from a transient false-negative. */
|
|
129
|
+
readonly observed?: string;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
/** A single published GitHub Release, reduced to the two fields the capability resolver reads: the
|
|
@@ -448,6 +455,51 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
448
455
|
return /^\d+(\.\d+)*$/.test(v) ? v : undefined;
|
|
449
456
|
}
|
|
450
457
|
|
|
458
|
+
/** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed
|
|
459
|
+
* (issue #514 Defect A). Lists the releases tagged `<match.package>@<version>` (newest first) and,
|
|
460
|
+
* for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can
|
|
461
|
+
* show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient
|
|
462
|
+
* false-negative where a matching release was live but its provenance body was momentarily empty).
|
|
463
|
+
* PURE / never throws — a malformed or empty list yields a plain "no releases observed" note.
|
|
464
|
+
* Output is capped (newest {@link CAPABILITY_SUMMARY_LIMIT}) so a repo with hundreds of releases
|
|
465
|
+
* cannot bloat the escalation form or a log line. Provenance is public — nothing here is redacted. */
|
|
466
|
+
export function summariseCapabilityCandidates(
|
|
467
|
+
match: ProbeMatch | undefined,
|
|
468
|
+
releases: readonly GithubRelease[],
|
|
469
|
+
): string {
|
|
470
|
+
const pkg = match?.package;
|
|
471
|
+
const ref = match?.capabilityRef;
|
|
472
|
+
if (!pkg) return "capability: no package configured";
|
|
473
|
+
const num = ref ? capabilityNumber(ref) : undefined;
|
|
474
|
+
const candidates: { version: string; refs: boolean }[] = [];
|
|
475
|
+
for (const rel of releases) {
|
|
476
|
+
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
477
|
+
const version = versionForPackage(rel.tag, pkg);
|
|
478
|
+
if (!version) continue;
|
|
479
|
+
candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) });
|
|
480
|
+
}
|
|
481
|
+
if (candidates.length === 0) return `no ${pkg} releases observed`;
|
|
482
|
+
candidates.sort((a, b) => cmpVersion(b.version, a.version)); // newest first, deterministic
|
|
483
|
+
const shown = candidates.slice(0, CAPABILITY_SUMMARY_LIMIT);
|
|
484
|
+
const more = candidates.length > shown.length ? ` (+${candidates.length - shown.length} more)` : "";
|
|
485
|
+
if (num === undefined) {
|
|
486
|
+
// No parseable ref number to test candidates against: distinguish a configured-but-unparseable ref
|
|
487
|
+
// from a genuinely absent one, and OMIT the per-candidate/aggregate "refs/no" flags — with nothing
|
|
488
|
+
// to match, every candidate would carry a meaningless "no", which is misleading rather than diagnostic.
|
|
489
|
+
const why = ref ? "(unparseable ref)" : "(no ref configured)";
|
|
490
|
+
const parts = shown.map((c) => `${pkg}@${c.version}`);
|
|
491
|
+
return `${candidates.length} ${pkg} release(s) observed ${why}: ${parts.join("; ")}${more}`;
|
|
492
|
+
}
|
|
493
|
+
const referencing = candidates.filter((c) => c.refs).length;
|
|
494
|
+
const refLabel = `#${num}`;
|
|
495
|
+
const parts = shown.map((c) => `${pkg}@${c.version} ${c.refs ? `refs ${refLabel}` : `no ${refLabel}`}`);
|
|
496
|
+
return `${candidates.length} ${pkg} release(s) observed, ${referencing} referencing ${refLabel}: ${parts.join("; ")}${more}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Newest-N cap on {@link summariseCapabilityCandidates} so a >100-release repo cannot bloat the
|
|
500
|
+
* escalation form or a warn log line. */
|
|
501
|
+
const CAPABILITY_SUMMARY_LIMIT = 8;
|
|
502
|
+
|
|
451
503
|
/** capability readiness (#274 Gap A): among GitHub Releases tagged `<match.package>@*` whose body
|
|
452
504
|
* references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that
|
|
453
505
|
* *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the
|
|
@@ -457,9 +509,10 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
457
509
|
export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
|
|
458
510
|
const pkg = match?.package;
|
|
459
511
|
const ref = match?.capabilityRef;
|
|
460
|
-
|
|
512
|
+
const observed = summariseCapabilityCandidates(match, releases);
|
|
513
|
+
if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed };
|
|
461
514
|
const num = capabilityNumber(ref);
|
|
462
|
-
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)" };
|
|
515
|
+
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed };
|
|
463
516
|
|
|
464
517
|
let firstVersion: string | undefined;
|
|
465
518
|
for (const rel of releases) {
|
|
@@ -469,9 +522,9 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
469
522
|
if (!bodyReferences(rel.body, num)) continue;
|
|
470
523
|
if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
|
|
471
524
|
}
|
|
472
|
-
if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet
|
|
525
|
+
if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet`, observed };
|
|
473
526
|
const resolvedArtifact = `${pkg}@${firstVersion}`;
|
|
474
|
-
return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact } };
|
|
527
|
+
return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact }, observed };
|
|
475
528
|
}
|
|
476
529
|
|
|
477
530
|
/** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated
|