@nanobpm/nano-workforce 0.190.0 → 0.191.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/.github/workflows/mirror-install-dispatch.yml +6 -6
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraph.test.ts +447 -0
- package/app/deliveryGraph.ts +356 -1
- package/app/deliveryGraphCompiler.test.ts +1465 -3
- package/app/deliveryGraphCompiler.ts +833 -36
- package/app/deliveryGraphDispatch.test.ts +118 -2
- package/app/deliveryGraphDispatch.ts +138 -10
- package/app/deliveryGraphProposals.test.ts +243 -0
- package/app/deliveryGraphProposals.ts +130 -56
- package/app/deliveryGraphRun.test.ts +67 -4
- package/app/deliveryGraphRun.ts +62 -10
- package/app/deliveryHuman.test.ts +79 -2
- package/app/deliveryHuman.ts +92 -0
- package/app/deliveryRunner.test.ts +21 -0
- package/app/deliveryRunner.ts +9 -2
- package/app/mergeProtocol.ts +1 -1
- package/app/pollUserTasks.test.ts +39 -0
- package/app/readiness.test.ts +72 -0
- package/app/readiness.ts +234 -8
- package/app/service.ts +8 -3
- package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
- package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
- package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
- package/docs/adr/0003-epic-base-branch-admission.md +1 -1
- package/docs/adr/0004-shared-contract-coordination.md +1 -1
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/adr/0006-delivery-units-one-representation.md +1 -1
- package/operations/dispatchDeliveryGraph.test.ts +255 -0
- package/operations/dispatchDeliveryGraph.ts +156 -5
- package/package.json +1 -1
- package/workers/merge/worker.test.ts +2 -2
- package/workers/merge/worker.ts +1 -1
|
@@ -36,16 +36,73 @@ import type {
|
|
|
36
36
|
ResolvedDeliveryNode,
|
|
37
37
|
} from "../nano-generated/api-io.d.ts";
|
|
38
38
|
import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcript-url.ts";
|
|
39
|
+
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
39
40
|
import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
|
|
40
41
|
import {
|
|
41
42
|
analyzeExclusiveTopology,
|
|
43
|
+
canonicalJson,
|
|
42
44
|
type DeliveryGraphError,
|
|
43
45
|
deliveryNodeFacts,
|
|
46
|
+
hasXmlInvalidChars,
|
|
47
|
+
redactConnectorValue,
|
|
44
48
|
resolveDeliveryFrom,
|
|
49
|
+
stripXmlInvalidChars,
|
|
45
50
|
validateDeliveryGraph,
|
|
46
51
|
} from "./deliveryGraph.ts";
|
|
47
52
|
import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
|
|
53
|
+
import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactEmbeddedCredential, redactString } from "./readiness.ts";
|
|
48
54
|
import { AGENT_TASK_NS } from "./repoEnvelope.ts";
|
|
55
|
+
import { isoDuration } from "./reviewWait.ts";
|
|
56
|
+
|
|
57
|
+
/** A display-safe rendering of a `wait` probe's target for user-visible BPMN name/documentation
|
|
58
|
+
* (issue #778 review): a `command` target is an arbitrary shell snippet that can embed a secret, so it
|
|
59
|
+
* is never surfaced — only a fixed placeholder; an `http` target can carry a credential in its
|
|
60
|
+
* `user:pass@` userinfo or `?query`/`#fragment`, so it goes through the same {@link redactString} log
|
|
61
|
+
* redaction. A structured, non-credential identifier (`owner/repo#42`, `pkg@version`, `owner/repo@ref`,
|
|
62
|
+
* a `<node>.<fact>` ref) is shown VERBATIM — but because the graph validator only requires a non-empty
|
|
63
|
+
* target (and `parsePrTarget` accepts any prefix before `#<digits>`), a URL-SHAPED value smuggled into
|
|
64
|
+
* one of those kinds is still routed through the same URL-only {@link redactConnectorValue}, so a
|
|
65
|
+
* `//user:pass@host/repo#42` has its credential stripped while a legitimate `#`/`@` in a structured ref
|
|
66
|
+
* (which is not URL-shaped) is left untouched. The RAW target is retained only in runtime variables (the
|
|
67
|
+
* probe config + the escalation diagnostics), never in the deployed documentation the explorer/modeler
|
|
68
|
+
* shows. */
|
|
69
|
+
function redactProbeTargetForDisplay(probe: Extract<DeliveryNode, { kind: "wait" }>["wait"]): string {
|
|
70
|
+
// NORMALIZE the kind first — `parseProbe` (`readiness.ts`) trims `wait.kind` before the worker keys
|
|
71
|
+
// on it, and the semantic validator only requires a non-empty string, so an internal/direct graph
|
|
72
|
+
// with `kind: " command "` runs as a COMMAND probe at runtime. Comparing the RAW (padded) kind here
|
|
73
|
+
// would miss that, route its arbitrary shell target through the URL-only `redactConnectorValue`, and
|
|
74
|
+
// leak it into the deployed BPMN documentation instead of `<redacted>` (issue #778 review — thread
|
|
75
|
+
// deliveryGraphCompiler.ts:70). Classify on the TRIMMED-RAW kind — the SAME `str(raw.kind).trim()`
|
|
76
|
+
// the runtime `parseProbe` keys on — and do NOT `stripXmlInvalidChars` it first: an XML-invalid char
|
|
77
|
+
// is not whitespace `parseProbe` trims, so a control-char-smuggled kind (`kind: "command\x01"`,
|
|
78
|
+
// `kind: "pr\x01"`) is REJECTED at runtime and never runs. Stripping the char before classifying
|
|
79
|
+
// would sanitise `"pr\x01"` toward a genuine `pr`, take the verbatim structured-target path, and leak
|
|
80
|
+
// an arbitrary secret-bearing target into the staged BPMN documentation for a probe that can never run
|
|
81
|
+
// (issue #778 review — thread deliveryGraphCompiler.ts:81, over :78). Any kind that is not an exact
|
|
82
|
+
// recognised `isProbeKind` after trimming falls to the unconditional `<redacted>` below.
|
|
83
|
+
const kind = probe.kind.trim();
|
|
84
|
+
if (kind === "command") return "<redacted>";
|
|
85
|
+
// TRIM the target first — `parseProbe` (`readiness.ts`) trims `target` for EVERY kind before the worker
|
|
86
|
+
// keys on it, so a padded ` owner/repo#1 ` and `owner/repo#1` are the SAME runtime probe. Rendering the
|
|
87
|
+
// raw (padded) value would leave the whitespace in `nodeDisplay`/`semanticBpmn`, forking the graph digest
|
|
88
|
+
// (and run key) from the trimmed-equivalent graph even though both probe the same value (issue #778
|
|
89
|
+
// review — thread deliveryGraphCompiler.ts:75).
|
|
90
|
+
const target = probe.target.trim();
|
|
91
|
+
// `stripXmlInvalidChars` BEFORE `redactString`: an XML-forbidden control (e.g. `\x0B`) hidden inside
|
|
92
|
+
// the `user:pass@` userinfo would otherwise split `redactString`'s `//…@` match, escape redaction, and
|
|
93
|
+
// be re-joined into a live credential once the renderer strips that control (issue #778 review).
|
|
94
|
+
if (kind === "http") return redactString(stripXmlInvalidChars(target));
|
|
95
|
+
// A kind that is not a recognised {@link isProbeKind} is MALFORMED — `parseProbe` rejects it at
|
|
96
|
+
// dispatch so it never runs, but the compiler still renders its target into the STAGED BPMN
|
|
97
|
+
// documentation/preview at compile time. That target could be an arbitrary command-like/secret-bearing
|
|
98
|
+
// snippet smuggled under a not-quite-known kind (`kind:"command\x01"`, `kind:"pr\x01"`, `kind:"cmd"`),
|
|
99
|
+
// so routing it through the URL-only `redactConnectorValue` would leak it verbatim. Redact it
|
|
100
|
+
// unconditionally, exactly like a `command` target — only a genuinely structured kind (`pr`/`epic`/
|
|
101
|
+
// `npm`/`github-check`/`capability`) shows its target (issue #778 review — thread
|
|
102
|
+
// deliveryGraphCompiler.ts:81, over :78).
|
|
103
|
+
if (!isProbeKind(kind)) return "<redacted>";
|
|
104
|
+
return redactConnectorValue(target);
|
|
105
|
+
}
|
|
49
106
|
|
|
50
107
|
/** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
|
|
51
108
|
* compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
|
|
@@ -132,9 +189,22 @@ export function assertNever(value: never, context: string): never {
|
|
|
132
189
|
throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
|
|
133
190
|
}
|
|
134
191
|
|
|
135
|
-
/**
|
|
192
|
+
/** XML 1.0's `Char` production forbids, anywhere in a document: the C0 control characters (except tab
|
|
193
|
+
* `#x9`, LF `#xA`, CR `#xD`), the noncharacters U+FFFE/U+FFFF, and unpaired UTF-16 surrogates — none can
|
|
194
|
+
* be represented by an entity, so any of them in an element `name`/`documentation` makes
|
|
195
|
+
* `layoutBpmn`/deployment reject the whole semantic BPMN. The character-class filter is
|
|
196
|
+
* {@link stripXmlInvalidChars}, canonical in `./deliveryGraph.ts` so the validator (which REJECTS such a
|
|
197
|
+
* character in executable FEEL) and this compiler (which strips it from DISPLAY text) share one
|
|
198
|
+
* definition. User-authored display strings (a node's free-form `prompt`, a probe `target`, an emit
|
|
199
|
+
* name) only impose length limits at the OpenAPI edge, so such a character can reach the renderer — and
|
|
200
|
+
* a code-unit truncation elsewhere can even manufacture a lone surrogate from a valid astral character.
|
|
201
|
+
* Strip all of these before emitting any XML text / attribute content (VALID astral pairs are
|
|
202
|
+
* preserved) — dropping an unrepresentable character is the only well-formed rendering. */
|
|
203
|
+
|
|
204
|
+
/** Escape a string for use as XML text / attribute content, first stripping XML-1.0-forbidden control
|
|
205
|
+
* characters (see {@link stripXmlInvalidChars}). Deterministic and total. */
|
|
136
206
|
function escapeXml(value: string): string {
|
|
137
|
-
return value
|
|
207
|
+
return stripXmlInvalidChars(value)
|
|
138
208
|
.replace(/&/g, "&")
|
|
139
209
|
.replace(/</g, "<")
|
|
140
210
|
.replace(/>/g, ">")
|
|
@@ -148,7 +218,7 @@ function escapeXml(value: string): string {
|
|
|
148
218
|
* literal survives to the engine. Safe because the compiler grafts DI onto its own semantic XML without
|
|
149
219
|
* re-serializing it, so these text nodes are never round-tripped/normalized. Deterministic and total. */
|
|
150
220
|
function escapeXmlText(value: string): string {
|
|
151
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
221
|
+
return stripXmlInvalidChars(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
152
222
|
}
|
|
153
223
|
|
|
154
224
|
/** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
|
|
@@ -159,16 +229,27 @@ function escapeXmlText(value: string): string {
|
|
|
159
229
|
* escaping) is used. Deterministic. */
|
|
160
230
|
function attr(name: string, value: string): string {
|
|
161
231
|
if (value.includes('"')) {
|
|
162
|
-
const inner = value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'");
|
|
232
|
+
const inner = stripXmlInvalidChars(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'");
|
|
163
233
|
return `${name}='${inner}'`;
|
|
164
234
|
}
|
|
165
235
|
return `${name}="${escapeXml(value)}"`;
|
|
166
236
|
}
|
|
167
237
|
|
|
168
|
-
/** Escape a string for use inside a mermaid quoted label.
|
|
169
|
-
*
|
|
238
|
+
/** Escape a string for use inside a mermaid quoted label. First strips XML-1.0-forbidden control
|
|
239
|
+
* characters (see {@link stripXmlInvalidChars}) — the same free-form node labels feed the Mermaid path,
|
|
240
|
+
* so a stray control char (e.g. `\x01` in a prompt) would otherwise make the preview unparsable. Mermaid
|
|
241
|
+
* then uses `#` HTML-entity escapes: `&`, `<`, `>` and `"` are encoded (`#amp;`/`#lt;`/`#gt;`/`#quot;`)
|
|
242
|
+
* so free-form prompt/target text can't be interpreted as markup and make a label render wrong or vanish
|
|
243
|
+
* (`&` is encoded FIRST so its `#amp;` isn't re-encoded). Any line break — LF **or** a bare/`\r\n` CR
|
|
244
|
+
* (a valid char `stripXmlInvalidChars` preserves) — is folded to a space, since a raw break inside the
|
|
245
|
+
* line-oriented Mermaid source would make the preview unparsable. */
|
|
170
246
|
function escapeMermaid(value: string): string {
|
|
171
|
-
return value
|
|
247
|
+
return stripXmlInvalidChars(value)
|
|
248
|
+
.replace(/&/g, "#amp;")
|
|
249
|
+
.replace(/</g, "#lt;")
|
|
250
|
+
.replace(/>/g, "#gt;")
|
|
251
|
+
.replace(/"/g, "#quot;")
|
|
252
|
+
.replace(/\r\n?|\n/g, " ");
|
|
172
253
|
}
|
|
173
254
|
|
|
174
255
|
/** A node's typed emits, normalised to a stable array (absent → `[]`). */
|
|
@@ -711,15 +792,51 @@ function delegateTarget(node: DeliveryNode, element: string): string {
|
|
|
711
792
|
}
|
|
712
793
|
}
|
|
713
794
|
|
|
795
|
+
/** Redact any credential-bearing URL inside each emit's free-form `description` for the operator-facing
|
|
796
|
+
* preview projection, mirroring the `redactFreeText(trimmedOrEmpty(description))` the BPMN label embeds
|
|
797
|
+
* (`nodeDisplay`). Returns fresh `DeliveryFact` copies (never mutates the source), so the RAW descriptions
|
|
798
|
+
* on the runtime path are untouched (issue #778 review). */
|
|
799
|
+
function redactEmitsForPreview(facts: readonly DeliveryFact[]): DeliveryFact[] {
|
|
800
|
+
return facts.map((f) =>
|
|
801
|
+
typeof f.description === "string" ? { ...f, description: redactFreeText(f.description) } : { ...f },
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
714
805
|
/** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
|
|
715
806
|
* instruction, optional attached form, and the typed facts the node will emit. */
|
|
716
807
|
function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
|
|
717
808
|
const stops: DeliveryHumanStop[] = [];
|
|
718
809
|
for (const node of nodes) {
|
|
719
810
|
if (node.kind !== "human") continue;
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
811
|
+
// Redact a credential-bearing URL inside each emit's free-form `description` at its SOURCE, the SAME
|
|
812
|
+
// way `nodeDisplay` embeds it (`redactFreeText(trimmedOrEmpty(description))`): `humanNodes[]` — emits
|
|
813
|
+
// included — is persisted into the staged proposal `preview` and rendered on the Delivery Graphs page
|
|
814
|
+
// (and denormalised into parked-node labels), so a `//user:pass@…` in a fact description would leak
|
|
815
|
+
// unredacted through this operator-facing projection even though the BPMN label path redacts it. The
|
|
816
|
+
// RAW descriptions still reach the runtime `appendPrompt` (`renderEmitContract`) unmodified (#778).
|
|
817
|
+
const stop: DeliveryHumanStop = { nodeId: node.id, emits: redactEmitsForPreview(normaliseEmits(node)) };
|
|
818
|
+
// Redact the operator-facing preview `prompt` at its SOURCE with the SAME display-safe helper
|
|
819
|
+
// `nodeDisplay` renders with: this projection is persisted into the staged proposal `preview` and
|
|
820
|
+
// rendered verbatim on the Delivery Graphs page (and denormalised into the run's parked-node
|
|
821
|
+
// labels), so a URL credential in a human prompt (`//user:pass@…`) must be stripped here too — else
|
|
822
|
+
// it leaks unredacted through the preview even though the BPMN display path redacts it. The runtime
|
|
823
|
+
// user-task prompt seeded via the compiled BPMN `nodeInputs` (`deliveryRunner.buildNodeInput`,
|
|
824
|
+
// `case "human"`) is ALSO redacted the same way — it renders in the parked task's read-only form
|
|
825
|
+
// field, another display surface — so no leak path remains (issue #778 review). `redactFreeText` is
|
|
826
|
+
// a no-op for a credential-free prompt.
|
|
827
|
+
const withPrompt =
|
|
828
|
+
typeof node.human?.prompt === "string" ? { ...stop, prompt: redactFreeText(node.human.prompt) } : stop;
|
|
829
|
+
// The `formKey` is an opaque identifier a modeler/explorer reads verbatim off the staged proposal
|
|
830
|
+
// preview + Delivery Graphs page (and denormalised into parked-node labels), so a credential-bearing
|
|
831
|
+
// formKey (`//user:pass@…`) must be stripped here with the SAME `redactConnectorValue` the BPMN
|
|
832
|
+
// `Form:` documentation (nodeDisplay) and the digest-invisible traversal use — else it leaks
|
|
833
|
+
// unredacted through the preview. The RAW formKey still drives runtime form resolution
|
|
834
|
+
// (`deliveryHuman.ts` reads `node.human.formKey` directly), unmodified (issue #778 review).
|
|
835
|
+
stops.push(
|
|
836
|
+
typeof node.human?.formKey === "string"
|
|
837
|
+
? { ...withPrompt, formKey: redactConnectorValue(node.human.formKey) }
|
|
838
|
+
: withPrompt,
|
|
839
|
+
);
|
|
723
840
|
}
|
|
724
841
|
return stops;
|
|
725
842
|
}
|
|
@@ -734,16 +851,28 @@ function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[]
|
|
|
734
851
|
effects.push({
|
|
735
852
|
nodeId: node.id,
|
|
736
853
|
kind: "agent",
|
|
737
|
-
|
|
854
|
+
// Redact a credential-bearing `jobType` at its SOURCE with the display-safe helper `nodeDisplay`
|
|
855
|
+
// uses — this side-effect projection is persisted into the staged proposal preview, an
|
|
856
|
+
// operator-visible surface, so a URL-shaped jobType carrying a secret must not echo verbatim here
|
|
857
|
+
// any more than in the node label/documentation (issue #778 review — thread :1202).
|
|
858
|
+
description: `runs agent job \`${redactConnectorValue(node.agent.jobType)}\``,
|
|
738
859
|
});
|
|
739
860
|
} else if (node.kind === "connector") {
|
|
861
|
+
// Redact the operator-facing `target`/`dedupeKey` at their SOURCE with the SAME display-safe
|
|
862
|
+
// helper `nodeDisplay` renders with: this projection is persisted into the staged proposal
|
|
863
|
+
// `preview` and rendered on the Delivery Graphs page, so a URL credential in a connector target
|
|
864
|
+
// or dedupe key (`//user:pass@…`) must be stripped here too — else it leaks unredacted through
|
|
865
|
+
// the preview even though the BPMN display path redacts it. The RAW values still reach the
|
|
866
|
+
// runtime connector via the compiled BPMN `nodeInputs`, unmodified (issue #778 review).
|
|
740
867
|
const effect: DeliverySideEffect = {
|
|
741
868
|
nodeId: node.id,
|
|
742
869
|
kind: "connector",
|
|
743
|
-
description: `invokes connector target \`${node.connector.target}\``,
|
|
870
|
+
description: `invokes connector target \`${redactConnectorValue(node.connector.target)}\``,
|
|
744
871
|
};
|
|
745
872
|
effects.push(
|
|
746
|
-
|
|
873
|
+
typeof node.connector.dedupeKey === "string"
|
|
874
|
+
? { ...effect, dedupeKey: redactConnectorValue(node.connector.dedupeKey) }
|
|
875
|
+
: effect,
|
|
747
876
|
);
|
|
748
877
|
}
|
|
749
878
|
}
|
|
@@ -886,6 +1015,633 @@ function renderBpmn(
|
|
|
886
1015
|
return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
|
|
887
1016
|
}
|
|
888
1017
|
|
|
1018
|
+
/** The first non-empty line of a (possibly multi-line) string, trimmed and length-capped for use as a
|
|
1019
|
+
* concise element label. Returns `""` for a blank/undefined input; a line longer than `cap` is
|
|
1020
|
+
* truncated with an ellipsis. Truncation is by Unicode CODE POINT (`Array.from`), not UTF-16 code unit,
|
|
1021
|
+
* so slicing never splits an astral character (emoji etc.) into an unpaired surrogate — an unpaired
|
|
1022
|
+
* surrogate survives {@link stripXmlInvalidChars} and would make the emitted BPMN not well-formed.
|
|
1023
|
+
* The first-non-empty test is on each line's SANITIZED content ({@link stripXmlInvalidChars}), so a line
|
|
1024
|
+
* that is ONLY XML-forbidden control characters (which the renderer strips to nothing) is skipped rather
|
|
1025
|
+
* than selected — otherwise a prompt of only `\x01` would be chosen and render as a blank ` · <id>`
|
|
1026
|
+
* label instead of falling back to the job type / `Human decision`. The RAW (unsanitised) line is
|
|
1027
|
+
* returned for mixed text — the renderer sanitises it (issue #778 review). Deterministic. */
|
|
1028
|
+
function firstLine(value: string | undefined | null, cap = 72): string {
|
|
1029
|
+
if (typeof value !== "string") return "";
|
|
1030
|
+
const line = value.split(/\r?\n/).map((l) => l.trim()).find((l) => stripXmlInvalidChars(l).trim().length > 0) ?? "";
|
|
1031
|
+
const points = Array.from(line);
|
|
1032
|
+
return points.length > cap ? `${Array.from(points.slice(0, cap - 1)).join("").trimEnd()}…` : line;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Trim an optional value to a non-blank string, or `""` when absent/blank. */
|
|
1036
|
+
function trimmedOrEmpty(value: unknown): string {
|
|
1037
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : "";
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** The runtime-effective node `timeout` fingerprint: the SAME normalisation the runtime applies
|
|
1041
|
+
* (`isoDuration` in `deliveryRunner` — trim + upper-case a valid ISO-8601 duration, else fall back to
|
|
1042
|
+
* the run-level default). A valid duration collapses whitespace/case variants that drive the identical
|
|
1043
|
+
* SLA (`"PT1H"`/`"PT1H "`/`"pt1h"` → `"PT1H"`); a malformed one (including an XML-invalid-char variant
|
|
1044
|
+
* like `"PT1H\x01"`) collapses to the `""` fallback sentinel — genuinely different from a valid value.
|
|
1045
|
+
* A non-string stays as-is so the caller's `typeof raw === "string"` guard skips an absent timeout
|
|
1046
|
+
* (issue #778 review — thread :1307). */
|
|
1047
|
+
function normaliseNodeTimeout(value: string | undefined | null): string | undefined | null {
|
|
1048
|
+
return typeof value === "string" ? isoDuration(value, "") : value;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** A runtime plain-object narrowing used where a statically-typed field may LIE at runtime because no
|
|
1052
|
+
* validator constrained its shape — notably a connector `payload`, whose schema is a forward-declared
|
|
1053
|
+
* `additionalProperties:true` stub the semantic validator does NOT shape-check (issue #778 review). A
|
|
1054
|
+
* bare primitive (`42`) or an array is NOT a record, so callers must gate on this before an `in`/`Object.keys`
|
|
1055
|
+
* probe that would otherwise THROW on a primitive. */
|
|
1056
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1057
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** URL-only credential redaction relocated to the low-level graph module ({@link redactConnectorValue}
|
|
1061
|
+
* in `deliveryGraph.ts`) so the DISPLAY path here and `validateDeliveryGraph`'s reject/error path share
|
|
1062
|
+
* ONE redactor — no drift surface (issue #778 review). */
|
|
1063
|
+
|
|
1064
|
+
/** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
|
|
1065
|
+
* `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
|
|
1066
|
+
* truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
|
|
1067
|
+
* surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
|
|
1068
|
+
* `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
|
|
1069
|
+
* punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
|
|
1070
|
+
* a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
|
|
1071
|
+
* the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
|
|
1072
|
+
* reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
|
|
1073
|
+
* and total. */
|
|
1074
|
+
export function redactFreeText(value: string): string {
|
|
1075
|
+
// Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
|
|
1076
|
+
// string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
|
|
1077
|
+
// the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
|
|
1078
|
+
// `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
|
|
1079
|
+
// connector/probe strip-before-classify fix).
|
|
1080
|
+
const cleaned = stripXmlInvalidChars(value);
|
|
1081
|
+
// Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
|
|
1082
|
+
// (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
|
|
1083
|
+
// spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
|
|
1084
|
+
// collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
|
|
1085
|
+
// its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
|
|
1086
|
+
// CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
|
|
1087
|
+
// credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
|
|
1088
|
+
// (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
|
|
1089
|
+
// stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
|
|
1090
|
+
// span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
|
|
1091
|
+
// SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
|
|
1092
|
+
// visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
|
|
1093
|
+
// marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
|
|
1094
|
+
// the query/fragment belt below.
|
|
1095
|
+
const credStripped = redactEmbeddedCredential(cleaned);
|
|
1096
|
+
// Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
|
|
1097
|
+
// `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
|
|
1098
|
+
// each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
|
|
1099
|
+
// CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
|
|
1100
|
+
// past the break is indistinguishable from a split value, so we never keep the far side (issue #778
|
|
1101
|
+
// review — thread deliveryGraphCompiler.ts:1115).
|
|
1102
|
+
const belted = redactQueryFragmentSpans(credStripped);
|
|
1103
|
+
return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
|
|
1107
|
+
* whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
|
|
1108
|
+
* cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
|
|
1109
|
+
* already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
|
|
1110
|
+
* belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
|
|
1111
|
+
* `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
|
|
1112
|
+
* indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
|
|
1113
|
+
* prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
|
|
1114
|
+
* single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
|
|
1115
|
+
* regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
|
|
1116
|
+
* #778 review). Deterministic and total. */
|
|
1117
|
+
function redactQueryFragmentSpans(text: string): string {
|
|
1118
|
+
let out = "";
|
|
1119
|
+
let i = 0;
|
|
1120
|
+
for (;;) {
|
|
1121
|
+
const start = text.indexOf("//", i);
|
|
1122
|
+
if (start < 0) return out + text.slice(i);
|
|
1123
|
+
out += text.slice(i, start);
|
|
1124
|
+
let end = start;
|
|
1125
|
+
while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
|
|
1126
|
+
const span = text.slice(start, end);
|
|
1127
|
+
const qMark = span.indexOf("?");
|
|
1128
|
+
const hMark = span.indexOf("#");
|
|
1129
|
+
const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
|
|
1130
|
+
// CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
|
|
1131
|
+
// (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
|
|
1132
|
+
// INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
|
|
1133
|
+
// prompt still reaches the runtime job input unmodified (issue #778 review — thread
|
|
1134
|
+
// deliveryGraphCompiler.ts:1115).
|
|
1135
|
+
out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
|
|
1136
|
+
i = end;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** A human-readable label for a connector node's `target`. The converge-enrollment vocabulary
|
|
1141
|
+
* (`convergeTargets.ts`) maps to intent-revealing phrases; any other (forward-declared) target is shown
|
|
1142
|
+
* through {@link redactConnectorValue} — an opaque identifier (`slack:#releases`) survives unchanged, but
|
|
1143
|
+
* a credential-bearing URL (`//user:pass@…`, `?token=…`) has its secret components stripped so it is
|
|
1144
|
+
* never persisted into user-visible BPMN documentation/modeler. Deterministic. */
|
|
1145
|
+
function humanizeConnectorTarget(target: string): string {
|
|
1146
|
+
switch (target) {
|
|
1147
|
+
case CONVERGE_TARGET:
|
|
1148
|
+
return "Converge PR (review only)";
|
|
1149
|
+
case CONVERGE_MERGE_TARGET:
|
|
1150
|
+
return "Converge & merge PR";
|
|
1151
|
+
case MERGE_MAIN_TARGET:
|
|
1152
|
+
return "Merge to main";
|
|
1153
|
+
default:
|
|
1154
|
+
return `Connector: ${redactConnectorValue(target)}`;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Render a probe's `match` predicate as a compact `k=v, k=v` description (declared fields only).
|
|
1159
|
+
* Three fields are FREE-FORM, user-supplied secret-bearing values — `verifyCommand` (an arbitrary shell
|
|
1160
|
+
* command the capability probe runs at the gate boundary) and `bodyIncludes`/`stdoutIncludes` (arbitrary
|
|
1161
|
+
* response-body / stdout substrings that can carry response tokens) — so they are never surfaced in the
|
|
1162
|
+
* user-visible name/documentation; only a fixed `<redacted>` placeholder appears while the raw value
|
|
1163
|
+
* survives in the runtime probe config. Every other declared field is a structured/enumerable predicate,
|
|
1164
|
+
* but not all are safe to interpolate verbatim — a free-form string predicate (`capabilityRef`,
|
|
1165
|
+
* `package`, `checkName`, …) can carry a credential-bearing URL (`//user:pass@host#274`, which
|
|
1166
|
+
* `parseProbe` accepts for its trailing id), so each is run through {@link redactConnectorValue} (a
|
|
1167
|
+
* URL-only redactor: an ordinary `status=200`/`checkName=build` passes through untouched, a URL has its
|
|
1168
|
+
* credential stripped). The raw value still survives in the runtime probe config; any value the display
|
|
1169
|
+
* redacts is fingerprinted by `digestInvisibleRawValues` so the digest/run-key stays faithful. Fields are
|
|
1170
|
+
* emitted in a STABLE code-unit key order (not the caller's JSON insertion order) so two semantically
|
|
1171
|
+
* identical graphs render byte-identically — preserving the compiler's determinism/digest guarantee.
|
|
1172
|
+
* (Issue #778 review.) */
|
|
1173
|
+
const REDACTED_MATCH_FIELDS: ReadonlySet<string> = new Set(["verifyCommand", "bodyIncludes", "stdoutIncludes"]);
|
|
1174
|
+
/** The DECLARED `ProbeMatch` fields (app/readiness.ts). `validateDeliveryGraph` does NOT reject an
|
|
1175
|
+
* unknown extra `wait.match` key, so a text-ingress graph can smuggle an arbitrary attacker-named key
|
|
1176
|
+
* whose value would otherwise be rendered VERBATIM into the user-visible preview/documentation here (a
|
|
1177
|
+
* credential-leak channel — an unknown key bypasses the `REDACTED_MATCH_FIELDS` set entirely). Only these
|
|
1178
|
+
* declared, enumerable predicate fields are rendered; any other key is dropped from the display (the raw
|
|
1179
|
+
* value still survives untouched in the runtime probe config). Keep in sync with `ProbeMatch`. */
|
|
1180
|
+
const DECLARED_MATCH_FIELDS: ReadonlySet<string> = new Set([
|
|
1181
|
+
"status",
|
|
1182
|
+
"bodyIncludes",
|
|
1183
|
+
"exitCode",
|
|
1184
|
+
"stdoutIncludes",
|
|
1185
|
+
"version",
|
|
1186
|
+
"conclusion",
|
|
1187
|
+
"checkName",
|
|
1188
|
+
"capabilityRef",
|
|
1189
|
+
"package",
|
|
1190
|
+
"verifyCommand",
|
|
1191
|
+
"prState",
|
|
1192
|
+
"epicState",
|
|
1193
|
+
]);
|
|
1194
|
+
|
|
1195
|
+
/** The declared `wait.match` fields `parseMatch` (`app/readiness.ts`) reads through `num()` — a value of
|
|
1196
|
+
* any OTHER type coerces to `undefined` (the predicate is treated as UNSET). Every other declared field is
|
|
1197
|
+
* read through `str(v).trim()`, so a numeric `conclusion:1` and a string `conclusion:"1"` coerce to the
|
|
1198
|
+
* SAME probe value (`"1"`) and parse IDENTICALLY. `describeProbeMatch` renders `String(v)`, so both also
|
|
1199
|
+
* render `conclusion=1` — genuinely one identity, NOT a fork. Only a `num()`-backed field forks
|
|
1200
|
+
* invisibly: a numeric `status:200` and a string `status:"200"` render IDENTICALLY (`status=200`) yet
|
|
1201
|
+
* parse DIFFERENTLY (`200` vs. unset/any-2xx). `matchValueTypeMismatch` flags EXACTLY that numeric-field
|
|
1202
|
+
* cross-type case so it is fingerprinted; a string-field cross-type value is runtime-equivalent and must
|
|
1203
|
+
* NOT be flagged, or two identical graphs fork their stable run key and double-dispatch (issue #778
|
|
1204
|
+
* review — thread deliveryGraphCompiler.ts:1251). */
|
|
1205
|
+
const NUMERIC_MATCH_FIELDS: ReadonlySet<string> = new Set(["status", "exitCode"]);
|
|
1206
|
+
function matchValueTypeMismatch(key: string, value: unknown): boolean {
|
|
1207
|
+
// ONLY `num()`-backed fields are type-sensitive: a non-number coerces to "unset" (any-2xx), a
|
|
1208
|
+
// runtime-distinct meaning that renders identically. Every string field runs through `str(v).trim()`,
|
|
1209
|
+
// so a number and its string twin are the SAME probe — never a fork. Unknown keys are `parseMatch`-inert.
|
|
1210
|
+
return NUMERIC_MATCH_FIELDS.has(key) && typeof value !== "number";
|
|
1211
|
+
}
|
|
1212
|
+
/** The per-kind `match` field whose AUTHORED value equals the runtime matcher's DEFAULT — i.e. writing
|
|
1213
|
+
* it explicitly is behaviourally identical to OMITTING it. `matchPr` defaults `prState` to `"merged"`,
|
|
1214
|
+
* `matchEpic` defaults `epicState` to `"merged"`, `matchGithubCheck` defaults `conclusion` to
|
|
1215
|
+
* `"success"`, and `matchCommand` defaults `exitCode` to `0` (`readiness.ts`). Rendering an authored
|
|
1216
|
+
* default into the display doc forks `semanticBpmn`/the digest from the omitted-but-equivalent graph, so
|
|
1217
|
+
* two runtime-identical encodings get DISTINCT digests/run keys and dispatch TWICE instead of colliding
|
|
1218
|
+
* as `alreadyRunning`. {@link describeProbeMatch} drops a field equal to its kind's default so the two
|
|
1219
|
+
* collapse (issue #778 review — thread deliveryGraphCompiler.ts:1267). Only the fields whose matcher
|
|
1220
|
+
* reads a SCALAR default belong here: `http`'s `status` default is "any 2xx" (not a value), and `npm`'s
|
|
1221
|
+
* `version` default is the target's own `pkg@version` (not a constant), so neither has an
|
|
1222
|
+
* omitted-equivalent scalar. */
|
|
1223
|
+
const EFFECTIVE_MATCH_DEFAULT: ReadonlyMap<string, { field: string; value: string | number }> = new Map([
|
|
1224
|
+
["pr", { field: "prState", value: "merged" }],
|
|
1225
|
+
["epic", { field: "epicState", value: "merged" }],
|
|
1226
|
+
["github-check", { field: "conclusion", value: "success" }],
|
|
1227
|
+
["command", { field: "exitCode", value: 0 }],
|
|
1228
|
+
]);
|
|
1229
|
+
/** True when `[key,value]` is `kind`'s defaulted match field carrying exactly the runtime default — so
|
|
1230
|
+
* rendering it would spuriously fork the digest from the omitted-equivalent graph. String defaults are
|
|
1231
|
+
* compared trimmed (the matcher/`parseMatch` trim), numeric defaults by strict number equality (a
|
|
1232
|
+
* type-mismatched `"0"`/`"5"` is NOT collapsed here — {@link matchValueTypeMismatch} already fingerprints
|
|
1233
|
+
* it as digest-invisible). */
|
|
1234
|
+
function matchFieldIsEffectiveDefault(kind: string, key: string, value: unknown): boolean {
|
|
1235
|
+
const def = EFFECTIVE_MATCH_DEFAULT.get(kind);
|
|
1236
|
+
if (!def || def.field !== key) return false;
|
|
1237
|
+
if (typeof def.value === "number") return typeof value === "number" && value === def.value;
|
|
1238
|
+
return typeof value === "string" && value.trim() === def.value;
|
|
1239
|
+
}
|
|
1240
|
+
/** True when `value` is a string whose runtime-normalized (trimmed) form is EMPTY — `parseMatch`
|
|
1241
|
+
* (`app/readiness.ts`) reads every string predicate through `str(v).trim() || undefined`, so a `""` or
|
|
1242
|
+
* whitespace-only value coerces to `undefined`, i.e. it is runtime-equivalent to OMITTING the field.
|
|
1243
|
+
* Rendering it anyway (e.g. `bodyIncludes=<redacted>` for a redacted field, or `version=` for a plain
|
|
1244
|
+
* one) would fork `semanticBpmn`/the run key from the omitted-equivalent graph and double-dispatch, so
|
|
1245
|
+
* {@link describeProbeMatch} drops it (issue #778 review — thread deliveryGraphCompiler.ts:1254). Numbers/
|
|
1246
|
+
* booleans are never "empty" and pass through untouched. */
|
|
1247
|
+
function matchValueRuntimeEmpty(value: unknown): boolean {
|
|
1248
|
+
return typeof value === "string" && value.trim() === "";
|
|
1249
|
+
}
|
|
1250
|
+
function describeProbeMatch(kind: string, match: Extract<DeliveryNode, { kind: "wait" }>["wait"]["match"]): string {
|
|
1251
|
+
if (match === undefined || match === null) return "";
|
|
1252
|
+
// Render the NORMALISED (trimmed) string value — `parseMatch` (`readiness.ts`) trims each string
|
|
1253
|
+
// predicate before the worker uses it, so a padded `version:" 1.2.3 "` and `"1.2.3"` probe the same
|
|
1254
|
+
// value. Rendering the raw value would leave the whitespace in `semanticBpmn`, forking the digest/run
|
|
1255
|
+
// key from the trimmed-equivalent graph while both run the identical match (issue #778 review — thread
|
|
1256
|
+
// deliveryGraphCompiler.ts:1206). Non-string values (numbers/booleans) are shown as-is. A field whose
|
|
1257
|
+
// authored value equals its kind's runtime DEFAULT ({@link matchFieldIsEffectiveDefault}) is DROPPED —
|
|
1258
|
+
// writing e.g. `prState:"merged"` explicitly is identical to omitting it, so surfacing it would fork the
|
|
1259
|
+
// digest from the omitted-equivalent graph and double-dispatch (issue #778 review — thread :1267). A
|
|
1260
|
+
// string predicate whose trimmed value is EMPTY ({@link matchValueRuntimeEmpty}) is likewise DROPPED —
|
|
1261
|
+
// `parseMatch` coerces it to `undefined`, so it too is omitted-equivalent (issue #778 review — thread :1254).
|
|
1262
|
+
return Object.entries(match)
|
|
1263
|
+
.filter(([k, v]) => v !== undefined && v !== null && DECLARED_MATCH_FIELDS.has(k) && !matchValueRuntimeEmpty(v) && !matchFieldIsEffectiveDefault(kind, k, v))
|
|
1264
|
+
.sort(([a], [b]) => byCodeUnit(a, b))
|
|
1265
|
+
.map(([k, v]) => `${k}=${REDACTED_MATCH_FIELDS.has(k) ? "<redacted>" : redactConnectorValue(typeof v === "string" ? v.trim() : String(v))}`)
|
|
1266
|
+
.join(", ");
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** The SINGLE source of a compiled node's human-readable display (issue #778): a concise `name` label
|
|
1270
|
+
* derived from the node's typed config — with the node `id` retained as a stable ` · <id>` suffix for
|
|
1271
|
+
* correlation (edges/logs/DI reference ids) — plus a longer `documentation` string rendered as the
|
|
1272
|
+
* flow element's `<bpmn:documentation>` child. BOTH the subProcess wrapper and its inner task read
|
|
1273
|
+
* their name from here (they must NOT compute names independently — derivation over duplication).
|
|
1274
|
+
* Deterministic and total over the closed kind union. */
|
|
1275
|
+
export function nodeDisplay(node: DeliveryNode): { name: string; documentation: string } {
|
|
1276
|
+
const id = node.id;
|
|
1277
|
+
const emitsLabel = normaliseEmits(node)
|
|
1278
|
+
// Include a display-safe (URL-credential-redacted) `description`: the runtime `appendPrompt`
|
|
1279
|
+
// (`renderEmitContract`) embeds each fact's `description`, so two graphs differing ONLY in an emit
|
|
1280
|
+
// description dispatch DIFFERENT instructions — omitting it here would collapse them to one
|
|
1281
|
+
// `semanticBpmn`/digest and let keyless dispatch reuse the wrong prompt. A credential-bearing
|
|
1282
|
+
// description is redacted (and additionally flagged lossy by {@link graphCarriesRedactedSecrets})
|
|
1283
|
+
// (issue #778 review). */
|
|
1284
|
+
.map((e) => {
|
|
1285
|
+
const desc = redactFreeText(trimmedOrEmpty(e.description));
|
|
1286
|
+
return `${e.name} (${e.type})${desc ? ` — ${desc}` : ""}`;
|
|
1287
|
+
})
|
|
1288
|
+
.join(", ");
|
|
1289
|
+
const withId = (label: string): string => `${label} · ${id}`;
|
|
1290
|
+
switch (node.kind) {
|
|
1291
|
+
case "agent": {
|
|
1292
|
+
const a = node.agent;
|
|
1293
|
+
const policy = a.merge ? "converge+merge" : a.converge ? "converge" : "";
|
|
1294
|
+
const base = firstLine(typeof a.prompt === "string" ? redactFreeText(a.prompt) : a.prompt) || redactConnectorValue(a.jobType);
|
|
1295
|
+
const label = policy ? `${base} & ${policy}` : base;
|
|
1296
|
+
const doc: string[] = [`Agent job: ${redactConnectorValue(a.jobType)}`];
|
|
1297
|
+
const repo = trimmedOrEmpty(a.repository);
|
|
1298
|
+
const branch = trimmedOrEmpty(a.baseBranch);
|
|
1299
|
+
if (repo || branch) doc.push(`Target: ${repo || "(run repo)"}${branch ? `@${branch}` : ""}`);
|
|
1300
|
+
if (policy) doc.push(`Policy: ${policy}`);
|
|
1301
|
+
if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
|
|
1302
|
+
const agentTimeout = normaliseNodeTimeout(a.timeout);
|
|
1303
|
+
if (agentTimeout) doc.push(`Timeout: ${agentTimeout}`);
|
|
1304
|
+
const prompt = trimmedOrEmpty(a.prompt);
|
|
1305
|
+
if (prompt) doc.push(`Prompt: ${redactFreeText(prompt)}`);
|
|
1306
|
+
return { name: withId(label), documentation: doc.join("\n") };
|
|
1307
|
+
}
|
|
1308
|
+
case "connector": {
|
|
1309
|
+
const c = node.connector;
|
|
1310
|
+
// TRIM the target — the connector worker (`workers/delivery-connector/worker.ts`) trims `vars.target`
|
|
1311
|
+
// before matching the reserved converge/merge vocabulary AND before dispatch, so a padded
|
|
1312
|
+
// `" converge-merge "` executes as `converge-merge`. Humanising/redacting the RAW value would miss
|
|
1313
|
+
// the reserved-target switch (rendering the generic `Connector: converge-merge` instead of the
|
|
1314
|
+
// descriptive `Converge & merge PR`) and fork the `semanticBpmn`/digest from the trimmed-equivalent
|
|
1315
|
+
// graph that dispatches identically (issue #778 review — thread deliveryGraphCompiler.ts:1277).
|
|
1316
|
+
const target = c.target.trim();
|
|
1317
|
+
const doc: string[] = [`Connector target: ${redactConnectorValue(target)}`];
|
|
1318
|
+
if (trimmedOrEmpty(c.dedupeKey)) doc.push(`Dedupe key: ${redactConnectorValue(trimmedOrEmpty(c.dedupeKey))}`);
|
|
1319
|
+
// TRIM the bound `payload.pr` — but ONLY for a CONVERGE target. `resolveConvergePr`/`parsePr`
|
|
1320
|
+
// (`deliveryConnector`/`readiness`) trim `payload.pr` before matching, so for a converge/converge-merge
|
|
1321
|
+
// connector a padded `" impl.pr "` and `"impl.pr"` drive the SAME dispatch and MUST collapse to one
|
|
1322
|
+
// display/digest. A GENERIC (forward-declared) connector forwards `payload` UNCHANGED to its worker
|
|
1323
|
+
// (`workers/delivery-connector/worker.ts` runs `readConvergeInput` only for `isConvergeTarget(target)`),
|
|
1324
|
+
// so `{pr:" x "}` and `{pr:"x"}` are DIFFERENT runtime payloads — trimming them for display would
|
|
1325
|
+
// collapse two distinct dispatches into one digest and let the keyless dispatch fence reuse the wrong
|
|
1326
|
+
// payload (issue #783 review — thread deliveryGraphCompiler.ts:1376). So preserve the RAW `pr` unless
|
|
1327
|
+
// this is a converge target.
|
|
1328
|
+
const rawPr = c.payload && typeof c.payload.pr === "string" ? c.payload.pr : "";
|
|
1329
|
+
const boundPr = isConvergeTarget(target) ? rawPr.trim() : rawPr;
|
|
1330
|
+
if (boundPr) doc.push(`PR: ${redactConnectorValue(boundPr)}`);
|
|
1331
|
+
if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
|
|
1332
|
+
// Render the CANONICAL timeout (`isoDuration` — trim + upper-case, else the run default), the SAME
|
|
1333
|
+
// normalisation the runtime applies, so `pt1h`/`PT1H`/`PT1H ` collapse to one `semanticBpmn`/digest
|
|
1334
|
+
// instead of forking the run key on authored casing/whitespace (issue #778 review — thread
|
|
1335
|
+
// deliveryGraphCompiler.ts:1243).
|
|
1336
|
+
const connectorTimeout = normaliseNodeTimeout(c.timeout);
|
|
1337
|
+
if (connectorTimeout) doc.push(`Timeout: ${connectorTimeout}`);
|
|
1338
|
+
return { name: withId(humanizeConnectorTarget(target)), documentation: doc.join("\n") };
|
|
1339
|
+
}
|
|
1340
|
+
case "wait": {
|
|
1341
|
+
const p = node.wait;
|
|
1342
|
+
// NORMALISE the kind — `parseProbe` trims `wait.kind` before the worker runs the probe, and
|
|
1343
|
+
// `digestInvisibleRawValues` fingerprints the trimmed kind, but the label/documentation must trim it
|
|
1344
|
+
// too: a padded `" http "` runs the SAME probe as `"http"`, so rendering the raw kind would fork the
|
|
1345
|
+
// `semanticBpmn`/digest+name and let a re-stage bypass the idempotency fence and duplicate the run
|
|
1346
|
+
// (issue #778 review — thread deliveryGraphCompiler.ts:1296). `redactProbeTargetForDisplay` already
|
|
1347
|
+
// trims the kind internally for its command/http branch decision.
|
|
1348
|
+
const kind = p.kind.trim();
|
|
1349
|
+
const safeTarget = redactProbeTargetForDisplay(p);
|
|
1350
|
+
const doc: string[] = [`Readiness probe: ${kind}`, `Target: ${safeTarget}`];
|
|
1351
|
+
const match = describeProbeMatch(kind, p.match);
|
|
1352
|
+
if (match) doc.push(`Match: ${match}`);
|
|
1353
|
+
// `credentialEnv` names a DECLARED env-contract key (validated `isEnvKey`, never a secret value —
|
|
1354
|
+
// the secret is read from the ambient env at execution time), yet it is carried raw into the
|
|
1355
|
+
// runtime probe config where it selects the HTTP Authorization credential. Surfacing its safe
|
|
1356
|
+
// env-key NAME here makes it part of `semanticBpmn`, so two graphs differing only in `credentialEnv`
|
|
1357
|
+
// get DISTINCT digests instead of colliding — the content-address stays a faithful identity and
|
|
1358
|
+
// keyless dispatch cannot reuse the wrong credential's running instance (issue #778 review).
|
|
1359
|
+
if (trimmedOrEmpty(p.credentialEnv)) doc.push(`Credential env: ${trimmedOrEmpty(p.credentialEnv)}`);
|
|
1360
|
+
// `parseProbe` defaults an OMITTED `onTimeout` to `escalate` ({@link DEFAULT_ON_TIMEOUT}) and
|
|
1361
|
+
// `waitBodyLines` only changes topology for `continue`, so an EXPLICIT `escalate` is behaviourally
|
|
1362
|
+
// identical to omitting it. Surfacing it in the doc would fork `semanticBpmn`/the content digest
|
|
1363
|
+
// from the omitted-equivalent graph (which `digestInvisibleRawValues` does NOT fingerprint —
|
|
1364
|
+
// `trimmedOrEmpty("")` == its display, so it is not pushed), giving two runtime-identical encodings
|
|
1365
|
+
// DISTINCT run keys that dispatch TWICE instead of colliding. DROP the effective default after
|
|
1366
|
+
// trimming, exactly as {@link matchFieldIsEffectiveDefault} does for default match fields — emit the
|
|
1367
|
+
// line only for a non-default (`continue`) routing (issue #778 review — thread
|
|
1368
|
+
// deliveryGraphCompiler.ts:1398).
|
|
1369
|
+
const onTimeout = trimmedOrEmpty(p.onTimeout);
|
|
1370
|
+
if (onTimeout && onTimeout !== DEFAULT_ON_TIMEOUT) doc.push(`On timeout: ${onTimeout}`);
|
|
1371
|
+
if (p.poll) {
|
|
1372
|
+
// Render the CANONICAL EFFECTIVE poll policy — the SAME `normalizePoll` the runtime applies —
|
|
1373
|
+
// NOT the authored fields, so two runtime-EQUIVALENT graphs share one `semanticBpmn`/digest
|
|
1374
|
+
// instead of forking the content address on an encoding difference the runtime collapses. The
|
|
1375
|
+
// runtime `normalizePoll` (a) falls a sub-1ms / non-numeric `everyMs`/`timeoutMs` back to its
|
|
1376
|
+
// default, (b) truncates a fractional value, (c) clamps `everyMs` to `MAX_EVERY_MS`, and (d)
|
|
1377
|
+
// defaults an omitted `backoff` to `exponential`. So `poll:{everyMs:0, backoff:"exponential"}`
|
|
1378
|
+
// runs IDENTICALLY to an omitted/default poll, yet rendering the raw `every 0ms, exponential
|
|
1379
|
+
// backoff` forked the digest — letting a re-stage bypass the idempotency fence and duplicate the
|
|
1380
|
+
// run (issue #778 review — thread deliveryGraphCompiler.ts:1414). SUPPRESS each field that equals
|
|
1381
|
+
// its effective default (matching the omitted-poll rendering, which shows no line at all), so a
|
|
1382
|
+
// graph whose poll normalises entirely to the defaults renders identically to one with no poll.
|
|
1383
|
+
const effective = normalizePoll(p.poll);
|
|
1384
|
+
const budget: string[] = [];
|
|
1385
|
+
if (effective.everyMs !== DEFAULT_EVERY_MS) budget.push(`every ${effective.everyMs}ms`);
|
|
1386
|
+
if (effective.timeoutMs !== DEFAULT_TIMEOUT_MS) budget.push(`timeout ${effective.timeoutMs}ms`);
|
|
1387
|
+
if (effective.backoff !== DEFAULT_BACKOFF) budget.push(`${effective.backoff} backoff`);
|
|
1388
|
+
if (budget.length > 0) doc.push(`Poll: ${budget.join(", ")}`);
|
|
1389
|
+
}
|
|
1390
|
+
if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
|
|
1391
|
+
return { name: withId(`Wait: ${kind} ${safeTarget}`), documentation: doc.join("\n") };
|
|
1392
|
+
}
|
|
1393
|
+
case "human": {
|
|
1394
|
+
const h = node.human;
|
|
1395
|
+
const base = firstLine(typeof h?.prompt === "string" ? redactFreeText(h.prompt) : h?.prompt) || "Human decision";
|
|
1396
|
+
const doc: string[] = [];
|
|
1397
|
+
const prompt = trimmedOrEmpty(h?.prompt);
|
|
1398
|
+
doc.push(prompt ? `Prompt: ${redactFreeText(prompt)}` : "Human decision step");
|
|
1399
|
+
if (trimmedOrEmpty(h?.formKey)) doc.push(`Form: ${redactConnectorValue(trimmedOrEmpty(h?.formKey))}`);
|
|
1400
|
+
if (emitsLabel) doc.push(`Emits: ${emitsLabel}`);
|
|
1401
|
+
return { name: withId(base), documentation: doc.join("\n") };
|
|
1402
|
+
}
|
|
1403
|
+
default:
|
|
1404
|
+
return assertNever(node, "nodeDisplay");
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** The raw field values a graph carries that the redacted `semanticBpmn` DROPS or COLLAPSES — i.e. the
|
|
1409
|
+
* content that reaches the runtime `nodeInputs` but is NOT faithfully represented in the digest, so two
|
|
1410
|
+
* graphs differing ONLY here compile to IDENTICAL `semanticBpmn` → identical digest (issue #778 review,
|
|
1411
|
+
* issue #716 content-address). Each entry is namespaced `nodeId\0field\0rawValue` so it also captures
|
|
1412
|
+
* WHICH node/field differs. This ONE traversal is the single source of truth for BOTH:
|
|
1413
|
+
* • {@link graphCarriesRedactedSecrets} — non-empty ⇒ the digest is not a faithful identity, so a
|
|
1414
|
+
* keyless dispatch must be disambiguated (Option C), and
|
|
1415
|
+
* • the dispatch run-key ({@link stableProposalRunKey}) — which fingerprints this list ALONGSIDE the
|
|
1416
|
+
* semantic digest, so credential-differing graphs get distinct run-keys while the digest collapses
|
|
1417
|
+
* every compiler-normalised default/reorder (no per-field enumeration to drift).
|
|
1418
|
+
* Digest-invisible content is: `user:pass@`/`?query`/`#fragment` URL credentials redacted from a
|
|
1419
|
+
* `target`/`prompt`/`dedupeKey`/`formKey`/emit-`description`; a `command`-probe `target`, the free-form
|
|
1420
|
+
* `verifyCommand`/`bodyIncludes`/`stdoutIncludes` match secrets (all shown only as `<redacted>`); a
|
|
1421
|
+
* non-redacted `match` value whose raw form loses characters to XML-1.0 sanitisation
|
|
1422
|
+
* ({@link hasXmlInvalidChars}) at serialisation; and the free-form connector `payload` (only a safe
|
|
1423
|
+
* `pr` string is surfaced). Deterministic; reuses the SAME redaction helpers `nodeDisplay` renders
|
|
1424
|
+
* with, so the set can never drift from what is actually stripped. */
|
|
1425
|
+
export function digestInvisibleRawValues(graph: DeliveryGraph): string[] {
|
|
1426
|
+
const out: string[] = [];
|
|
1427
|
+
// `raw` is digest-invisible when a present string does NOT match the EXACT `display` form `nodeDisplay`
|
|
1428
|
+
// embeds in `semanticBpmn` — apply the SAME normalisation (`trimmedOrEmpty`/redaction) the display does,
|
|
1429
|
+
// or a difference the display collapses (redaction OR trimmed whitespace) escapes while still reaching
|
|
1430
|
+
// the runtime raw.
|
|
1431
|
+
const push = (nodeId: string, field: string, raw: string | undefined | null, display: string | undefined | null): void => {
|
|
1432
|
+
if (typeof raw === "string" && raw !== display) out.push(`${nodeId}\u0000${field}\u0000${raw}`);
|
|
1433
|
+
};
|
|
1434
|
+
for (const node of graph.nodes) {
|
|
1435
|
+
const id = node.id;
|
|
1436
|
+
// Emit `description` is embedded in `emitsLabel` as `redactFreeText(trimmedOrEmpty(description))`
|
|
1437
|
+
// (see `nodeDisplay`) AND in the runtime `appendPrompt` (`renderEmitContract`) raw, so a
|
|
1438
|
+
// credential-bearing description whose redaction drops content is digest-invisible.
|
|
1439
|
+
for (const fact of normaliseEmits(node)) {
|
|
1440
|
+
push(id, `emit.${fact.name}.description`, fact.description, redactFreeText(trimmedOrEmpty(fact.description)));
|
|
1441
|
+
}
|
|
1442
|
+
switch (node.kind) {
|
|
1443
|
+
case "agent":
|
|
1444
|
+
// Display embeds `redactFreeText(trimmedOrEmpty(prompt))`; the RAW, untrimmed prompt reaches the
|
|
1445
|
+
// runtime (`buildNodeInput`), so a leading/trailing-whitespace-only difference is invisible too.
|
|
1446
|
+
push(id, "agent.prompt", node.agent.prompt, redactFreeText(trimmedOrEmpty(node.agent.prompt)));
|
|
1447
|
+
// `jobType` is a required non-empty string but only shape-validated, so it can smuggle a
|
|
1448
|
+
// credential-bearing URL (`//user:pass@host`). The display now redacts it through the SAME
|
|
1449
|
+
// `redactConnectorValue` (URL-only) rule, so a redacted jobType whose raw form the digest cannot
|
|
1450
|
+
// see reaches the worker verbatim (`cfg("jobType")`) — fingerprint the raw whenever it differs
|
|
1451
|
+
// from the redacted display (issue #778 review — thread :1202). An ordinary `senior:feature`
|
|
1452
|
+
// is not URL-shaped, so display == raw and nothing is pushed.
|
|
1453
|
+
push(id, "agent.jobType", node.agent.jobType, redactConnectorValue(node.agent.jobType));
|
|
1454
|
+
// The node `timeout` is embedded in the display doc as `trimmedOrEmpty(timeout)` then XML-
|
|
1455
|
+
// serialised (`escapeXml` STRIPS XML-1.0-invalid chars), yet the runtime reads it through
|
|
1456
|
+
// `isoDuration(timeout, nodeTimeout)` (deliveryRunner) — which TRIMS + upper-cases a valid value
|
|
1457
|
+
// and falls back to the run default on a malformed one. So `"PT1H"`, `"PT1H "`, and `"pt1h"` all
|
|
1458
|
+
// drive the SAME runtime SLA and must NOT be distinguished (else a whitespace-only OR case-only
|
|
1459
|
+
// re-stage launches a second run), while `"PT1H\x01"` (invalid ⇒ falls back) genuinely differs.
|
|
1460
|
+
// Fingerprint the ISO-normalised raw timeout AND compare it against the ISO-normalised form of what
|
|
1461
|
+
// the digest actually sees (the XML-sanitised trimmed display value) — normalising BOTH sides, so a
|
|
1462
|
+
// lowercase `"pt1h"` (digest sees `"pt1h"`, runtime canonicalises to `"PT1H"`) is NOT falsely marked
|
|
1463
|
+
// digest-invisible, while an XML-invalid variant (digest sees `"PT1H"` after the strip but the raw
|
|
1464
|
+
// falls back to the default) still is (issue #778 review — thread :1331, over :1307/:1251).
|
|
1465
|
+
push(id, "agent.timeout", normaliseNodeTimeout(node.agent.timeout), normaliseNodeTimeout(stripXmlInvalidChars(trimmedOrEmpty(node.agent.timeout))));
|
|
1466
|
+
break;
|
|
1467
|
+
case "human":
|
|
1468
|
+
push(id, "human.prompt", node.human?.prompt, redactFreeText(trimmedOrEmpty(node.human?.prompt)));
|
|
1469
|
+
// Runtime form resolution TRIMS an explicit formKey (`resolveHumanForm` in `deliveryHuman.ts`),
|
|
1470
|
+
// so a whitespace-only formKey variant has identical compiled form + runtime behaviour. Fingerprint
|
|
1471
|
+
// the NORMALISED (trimmed) value — the same value form resolution keys on — so a leading/trailing-
|
|
1472
|
+
// whitespace difference does NOT falsely mark the node digest-invisible and re-launch the whole
|
|
1473
|
+
// graph; only a genuine redaction difference (a real credential) survives (issue #778 review).
|
|
1474
|
+
push(id, "human.formKey", trimmedOrEmpty(node.human?.formKey), redactConnectorValue(trimmedOrEmpty(node.human?.formKey)));
|
|
1475
|
+
break;
|
|
1476
|
+
case "connector": {
|
|
1477
|
+
const c = node.connector;
|
|
1478
|
+
// The connector worker TRIMS `vars.target` before reserved-vocab matching AND dispatch, so a
|
|
1479
|
+
// whitespace-padded target has identical routing + runtime behaviour. Fingerprint the NORMALISED
|
|
1480
|
+
// (trimmed) value the worker keys on — matching the trimmed display — so a padding-only difference
|
|
1481
|
+
// does not fork the digest-invisible fingerprint (which would force an idempotencyKey and
|
|
1482
|
+
// double-launch a graph the runtime treats identically), while a real credential difference in the
|
|
1483
|
+
// trimmed value still survives (issue #778 review — thread deliveryGraphCompiler.ts:1277).
|
|
1484
|
+
push(id, "connector.target", trimmedOrEmpty(c.target), redactConnectorValue(trimmedOrEmpty(c.target)));
|
|
1485
|
+
// The connector worker TRIMS the authored dedupeKey (`connectorDedupeKey` in `deliveryConnector.ts`,
|
|
1486
|
+
// the SINGLE dedupe-key derivation site), so a whitespace-only variant has identical dedupe identity
|
|
1487
|
+
// + runtime behaviour. Fingerprint the NORMALISED (trimmed) value the worker keys on, so a trimmed-
|
|
1488
|
+
// whitespace difference does NOT falsely mark the node digest-invisible (which would force an
|
|
1489
|
+
// idempotencyKey and double-launch a graph the runtime treats identically); only a genuine redaction
|
|
1490
|
+
// difference (a real credential) survives (issue #778 review).
|
|
1491
|
+
push(id, "connector.dedupeKey", trimmedOrEmpty(c.dedupeKey), redactConnectorValue(trimmedOrEmpty(c.dedupeKey)));
|
|
1492
|
+
// The connector `timeout` is digest-invisible the same way an agent's is: displayed as
|
|
1493
|
+
// `trimmedOrEmpty(timeout)` then XML-sanitised, but read through `isoDuration(timeout, nodeTimeout)`
|
|
1494
|
+
// into the runtime SLA. Fingerprint the ISO-normalised value, compared with the ISO-normalised form
|
|
1495
|
+
// of the XML-sanitised display value (normalise BOTH sides) so a whitespace- OR case-only variant
|
|
1496
|
+
// collapses while an invalid one stays disambiguated (issue #778 review — thread :1331, over :1307).
|
|
1497
|
+
push(id, "connector.timeout", normaliseNodeTimeout(c.timeout), normaliseNodeTimeout(stripXmlInvalidChars(trimmedOrEmpty(c.timeout))));
|
|
1498
|
+
// The free-form connector `payload` survives raw into runtime `nodeInputs`, but `nodeDisplay`
|
|
1499
|
+
// surfaces at most a single NON-EMPTY string `payload.pr` (redacted). So the display FAITHFULLY
|
|
1500
|
+
// represents the payload ONLY when it is exactly `{ pr: <non-empty string> }` whose redaction is
|
|
1501
|
+
// a no-op; EVERY other shape leaves content the digest cannot see and is therefore invisible:
|
|
1502
|
+
// (a) a non-plain-object payload (a bare `42`/array — on which `"pr" in payload` would THROW, so
|
|
1503
|
+
// it MUST be gated before that probe), (b) a present-but-empty object or any extra key beyond
|
|
1504
|
+
// `pr`, (c) an omitted / non-string / empty `pr`, or (d) a string `pr` whose redaction drops
|
|
1505
|
+
// content. The whole raw payload (canonicalised so key order is not spuriously distinguishing)
|
|
1506
|
+
// is the disambiguator — but NORMALISE `pr` (trim) ONLY for a CONVERGE target: `nodeDisplay` and
|
|
1507
|
+
// the runtime both read `payload.pr.trim()` there (`resolveConvergePr`/`parsePr`), so a padded
|
|
1508
|
+
// credential-bearing `pr` and its trimmed twin are ONE runtime identity and MUST collapse
|
|
1509
|
+
// (fingerprinting the untrimmed payload forked their stable run key and double-launched the
|
|
1510
|
+
// connector side effect — issue #778 review — thread deliveryGraphCompiler.ts:1484). A GENERIC
|
|
1511
|
+
// (forward-declared) connector instead forwards `payload` UNCHANGED to its worker (`readConvergeInput`
|
|
1512
|
+
// in `workers/delivery-connector/worker.ts` runs only for `isConvergeTarget`), so `{pr:" x "}` and
|
|
1513
|
+
// `{pr:"x"}` are DISTINCT runtime payloads that MUST stay disambiguated — trimming them here would
|
|
1514
|
+
// collapse two different dispatches into one run key and let the keyless dispatch fence reuse the
|
|
1515
|
+
// wrong payload (issue #783 review — thread deliveryGraphCompiler.ts:1376). So trim only when the
|
|
1516
|
+
// target is a converge target.
|
|
1517
|
+
if (c.payload !== undefined && c.payload !== null) {
|
|
1518
|
+
const normalisedPayload =
|
|
1519
|
+
isConvergeTarget(trimmedOrEmpty(c.target)) && isRecord(c.payload) && typeof c.payload.pr === "string"
|
|
1520
|
+
? { ...c.payload, pr: c.payload.pr.trim() }
|
|
1521
|
+
: c.payload;
|
|
1522
|
+
let invisible = true;
|
|
1523
|
+
if (isRecord(normalisedPayload)) {
|
|
1524
|
+
const keys = Object.keys(normalisedPayload);
|
|
1525
|
+
const pr = normalisedPayload.pr;
|
|
1526
|
+
invisible = keys.length !== 1 || keys[0] !== "pr" || typeof pr !== "string" || pr === "" || pr !== redactConnectorValue(pr);
|
|
1527
|
+
}
|
|
1528
|
+
if (invisible) out.push(`${id}\u0000connector.payload\u0000${canonicalJson(normalisedPayload)}`);
|
|
1529
|
+
}
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
case "wait": {
|
|
1533
|
+
const p = node.wait;
|
|
1534
|
+
// `parseProbe` (`readiness.ts`) TRIMS `target` for EVERY kind before the worker keys on it, so a
|
|
1535
|
+
// leading/trailing-whitespace-only variant (` run-task ` vs `run-task`) is the SAME runtime probe.
|
|
1536
|
+
// For a `command` probe the display is a constant `<redacted>`, so without trimming the fingerprint
|
|
1537
|
+
// the digest would fork on that whitespace — a distinct staged run key for an identical runtime
|
|
1538
|
+
// probe, bypassing the idempotency fence. Fingerprint the TRIMMED target (all kinds) so it matches
|
|
1539
|
+
// the runtime-normalised value (issue #778 review — thread :1372).
|
|
1540
|
+
push(id, "wait.target", p.target.trim(), redactProbeTargetForDisplay(p));
|
|
1541
|
+
// `credentialEnv` names a DECLARED env-contract key, shown in the doc as `trimmedOrEmpty(...)` then
|
|
1542
|
+
// XML-sanitised at serialisation, while the runtime `parseProbe` reads it as `.trim()`. A value
|
|
1543
|
+
// carrying an XML-invalid char (`"GITHUB_TOKEN\x01"`) sanitises to the SAME display as valid
|
|
1544
|
+
// `"GITHUB_TOKEN"`, so without a fingerprint the malformed graph shares the valid graph's digest and
|
|
1545
|
+
// `graphCarriesRedactedSecrets` stays false — letting keyless dispatch short-circuit the malformed
|
|
1546
|
+
// proposal onto the valid running instance (and mark it dispatched) even though `parseProbe` would
|
|
1547
|
+
// reject the raw value. Fingerprint the trimmed raw against its XML-sanitised form so a whitespace-
|
|
1548
|
+
// only variant collapses (runtime trims) while an invalid-char one stays disambiguated (issue #778
|
|
1549
|
+
// review — thread :1355).
|
|
1550
|
+
push(id, "wait.credentialEnv", trimmedOrEmpty(p.credentialEnv), stripXmlInvalidChars(trimmedOrEmpty(p.credentialEnv)));
|
|
1551
|
+
// `kind`/`onTimeout`/`poll.backoff` are shown verbatim in the doc (`Readiness probe: <kind>`,
|
|
1552
|
+
// `On timeout: <onTimeout>`, `<backoff> backoff`) then XML-sanitised at serialisation, while the
|
|
1553
|
+
// runtime `parseProbe` reads their RAW values. Exactly like `credentialEnv` above, a value carrying
|
|
1554
|
+
// an XML-invalid char (`"http\x01"`) sanitises to the SAME display as the valid form, so without a
|
|
1555
|
+
// fingerprint the malformed probe shares a valid graph's digest and `graphCarriesRedactedSecrets`
|
|
1556
|
+
// stays false — letting keyless dispatch short-circuit the malformed proposal onto the valid
|
|
1557
|
+
// running instance (and mark it dispatched) even though `parseProbe` would reject the raw value.
|
|
1558
|
+
// Fingerprint each trimmed raw against its XML-sanitised form so a whitespace-only variant collapses
|
|
1559
|
+
// (runtime trims) while an invalid-char one stays disambiguated (issue #778 review — thread :1366,
|
|
1560
|
+
// same class as the credentialEnv :1355 fingerprint above).
|
|
1561
|
+
push(id, "wait.kind", trimmedOrEmpty(p.kind), stripXmlInvalidChars(trimmedOrEmpty(p.kind)));
|
|
1562
|
+
push(id, "wait.onTimeout", trimmedOrEmpty(p.onTimeout), stripXmlInvalidChars(trimmedOrEmpty(p.onTimeout)));
|
|
1563
|
+
push(id, "wait.poll.backoff", trimmedOrEmpty(p.poll?.backoff), stripXmlInvalidChars(trimmedOrEmpty(p.poll?.backoff)));
|
|
1564
|
+
if (p.match) {
|
|
1565
|
+
for (const [k, v] of Object.entries(p.match)) {
|
|
1566
|
+
if (v === undefined || v === null) continue;
|
|
1567
|
+
// A string predicate whose trimmed value is EMPTY is runtime-unset — `parseMatch` coerces a
|
|
1568
|
+
// string field through `str(v).trim() || undefined` and a numeric field through `num(v)` (a
|
|
1569
|
+
// non-number → undefined), so `""`/`" "` is omitted-equivalent on EITHER. `describeProbeMatch`
|
|
1570
|
+
// now drops it from the display (same digest as the omitted graph), so the run-key fingerprint
|
|
1571
|
+
// MUST drop it too — otherwise an empty variant pushes a token the omitted graph does not, forking
|
|
1572
|
+
// `stableProposalRunKey` under a shared digest and letting a keyless re-stage double-dispatch
|
|
1573
|
+
// (issue #778 review — thread deliveryGraphCompiler.ts:1254).
|
|
1574
|
+
if (matchValueRuntimeEmpty(v)) continue;
|
|
1575
|
+
// `verifyCommand`/`bodyIncludes`/`stdoutIncludes` are shown only as `<redacted>`; every other
|
|
1576
|
+
// match value is shown as `redactConnectorValue(String(v))`, which (a) XML-1.0 sanitisation
|
|
1577
|
+
// (`escapeXml`) later STRIPS invalid characters from — so `"1\x01"` and `"1"` share a digest —
|
|
1578
|
+
// AND (b) URL-redacts a credential-bearing free-form predicate (`capabilityRef`/`package`/
|
|
1579
|
+
// `checkName`) so `//user:pass@host#274` no longer surfaces the secret in the display. Either
|
|
1580
|
+
// transform leaves content the digest cannot see while the raw probe config differs; both cases
|
|
1581
|
+
// are digest-invisible and the raw value is the disambiguator (issue #778 review — thread :1169).
|
|
1582
|
+
if (REDACTED_MATCH_FIELDS.has(k)) {
|
|
1583
|
+
// A redacted field is ALWAYS invisible (display is `<redacted>`). `parseMatch` coerces these
|
|
1584
|
+
// free-form predicates through `str(v).trim()`, so fingerprint the SAME coercion — a
|
|
1585
|
+
// whitespace-only variant AND a number/string cross-type twin (`1` vs `"1"`) both collapse to
|
|
1586
|
+
// the one runtime match (an internal invalid char still distinguishes) instead of forking the
|
|
1587
|
+
// server-derived run key (issue #778 review — threads :1363 / deliveryGraphCompiler.ts:1251).
|
|
1588
|
+
// `v` is guaranteed non-null here; `str` is not exported, so inline `String(v).trim()`.
|
|
1589
|
+
out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(String(v).trim())}`);
|
|
1590
|
+
} else if (matchValueTypeMismatch(k, v)) {
|
|
1591
|
+
// A numeric field (`status`/`exitCode`) coerces through `num()`: a real number is a
|
|
1592
|
+
// distinct runtime match, but EVERY non-number (`"200"`, `" 200 "`, `"foo"`, `true`)
|
|
1593
|
+
// collapses to the SAME `undefined` (unset / any-2xx) match. Fingerprint that CANONICAL
|
|
1594
|
+
// coerced value — a constant `null` unset marker — NOT the raw `v`: otherwise two
|
|
1595
|
+
// runtime-equivalent non-number twins (`"200"` vs `" 200 "`, which also DISPLAY identically)
|
|
1596
|
+
// fork distinct `stableProposalRunKey`s and double-dispatch. The number/string cross-type
|
|
1597
|
+
// twin (`200` vs `"200"`) still forks correctly because the number takes the else-branch and
|
|
1598
|
+
// pushes nothing, while a genuinely different-DISPLAY string (`"foo"`) already forks via
|
|
1599
|
+
// `semanticBpmn` (issue #778 review — thread deliveryGraphCompiler.ts:1616).
|
|
1600
|
+
out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(null)}`);
|
|
1601
|
+
} else {
|
|
1602
|
+
// A non-redacted field is shown as `redactConnectorValue(String(v).trim())` — `describeProbeMatch`
|
|
1603
|
+
// trims a string predicate before display, and `parseMatch` likewise coerces via
|
|
1604
|
+
// `str(v).trim()` — so fingerprint the SAME trimmed form. Comparing/fingerprinting the RAW
|
|
1605
|
+
// `String(v)` instead forks the run key for a whitespace-only variant (`" //user:pass@host#1 "`
|
|
1606
|
+
// vs its trimmed twin) that parses to the ONE runtime match, so a keyless re-stage double-
|
|
1607
|
+
// dispatches an identical graph (issue #778 review — thread deliveryGraphCompiler.ts:1591).
|
|
1608
|
+
const tv = typeof v === "string" ? v.trim() : v;
|
|
1609
|
+
if (hasXmlInvalidChars(String(tv)) || redactConnectorValue(String(tv)) !== String(tv)) {
|
|
1610
|
+
out.push(`${id}\u0000wait.match.${k}\u0000${canonicalJson(tv)}`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
break;
|
|
1616
|
+
}
|
|
1617
|
+
default:
|
|
1618
|
+
return assertNever(node, "digestInvisibleRawValues");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
// Canonicalise the order so this list is a content IDENTITY, not an ENCODING one. Each entry is fully
|
|
1622
|
+
// self-identifying (`nodeId\0field\0raw`), so its position in `graph.nodes` iteration order carries no
|
|
1623
|
+
// information — but the run-key fingerprint (`stableProposalRunKey`) canonicalises this array with
|
|
1624
|
+
// `canonicalJson`, which PRESERVES array order. The compiler SORTS nodes before emitting `semanticBpmn`,
|
|
1625
|
+
// so two graphs differing ONLY in top-level node order share one digest; without this sort their
|
|
1626
|
+
// invisible-value lists would differ in order alone, yielding DISTINCT run-keys that double-launch the
|
|
1627
|
+
// same logical graph instead of short-circuiting as `alreadyRunning`. Sort by code unit (locale-
|
|
1628
|
+
// independent) so the fingerprint is reorder-invariant, matching the digest's node-reorder collapse
|
|
1629
|
+
// (issue #778 review). Sorting cannot drop or merge entries, so the emptiness check
|
|
1630
|
+
// (`graphCarriesRedactedSecrets`) and the credential-disambiguation are unaffected.
|
|
1631
|
+
out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1632
|
+
return out;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** Whether a graph's raw content carries values that redaction/normalisation DROPS from `semanticBpmn`
|
|
1636
|
+
* (issue #778 review — Option C): non-empty {@link digestInvisibleRawValues}. Two graphs differing ONLY
|
|
1637
|
+
* in such a value compile to IDENTICAL `semanticBpmn` → identical digest, so the digest is NOT a faithful
|
|
1638
|
+
* fingerprint for them; a dispatch must then REQUIRE an explicit `idempotencyKey` (or the stable
|
|
1639
|
+
* server-side run-key) to disambiguate, instead of silently collapsing the second onto the first's
|
|
1640
|
+
* still-running instance. `false` ⇒ the digest is a complete identity and no key is required. */
|
|
1641
|
+
export function graphCarriesRedactedSecrets(graph: DeliveryGraph): boolean {
|
|
1642
|
+
return digestInvisibleRawValues(graph).length > 0;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
889
1645
|
/** Render one node as an EMBEDDED `bpmn:subProcess` — the engine-native delegation unit (Decision 2).
|
|
890
1646
|
* Call activities are a no-op on the pinned WASM engine (the child is never instantiated), so — like
|
|
891
1647
|
* `plan-fanout`'s `readiness-preflight` — every node inlines a subProcess that shares the parent
|
|
@@ -904,15 +1660,25 @@ function renderNodeElement(
|
|
|
904
1660
|
requiredEmits: ReadonlySet<string>,
|
|
905
1661
|
): string {
|
|
906
1662
|
const el = w.element;
|
|
907
|
-
const
|
|
1663
|
+
const display = nodeDisplay(w.node);
|
|
1664
|
+
const name = escapeXml(display.name);
|
|
908
1665
|
const flowRefs = [
|
|
909
1666
|
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
910
1667
|
...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
|
|
911
1668
|
];
|
|
912
1669
|
const io = ioMappingLines(w, boundInputs);
|
|
913
|
-
const inner = innerBodyLines(w, requiredEmits);
|
|
1670
|
+
const inner = innerBodyLines(w, requiredEmits, display.name);
|
|
1671
|
+
// `<bpmn:documentation>` is the BPMN-native per-node description (a tooltip/details panel in the
|
|
1672
|
+
// explorer/modeler). It is a SEMANTIC child, so it survives the `layoutBpmn` DI graft untouched
|
|
1673
|
+
// (unlike a hand-edited DI block). Schema-wise it must be the flow element's FIRST child (before
|
|
1674
|
+
// `<extensionElements>`/`<incoming>`), so it slots ahead of the flow refs and io. Omitted when empty.
|
|
1675
|
+
const documentation =
|
|
1676
|
+
display.documentation.trim() !== ""
|
|
1677
|
+
? [` <bpmn:documentation>${escapeXml(display.documentation)}</bpmn:documentation>`]
|
|
1678
|
+
: [];
|
|
914
1679
|
const lines = [
|
|
915
1680
|
` <bpmn:subProcess id="${el}" name="${name}">`,
|
|
1681
|
+
...documentation,
|
|
916
1682
|
...flowRefs,
|
|
917
1683
|
" <bpmn:extensionElements>",
|
|
918
1684
|
...io,
|
|
@@ -956,7 +1722,13 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
956
1722
|
// target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
|
|
957
1723
|
// passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
|
|
958
1724
|
// authored value rather than raising a FEEL error.
|
|
959
|
-
|
|
1725
|
+
// TRIM the target before the bind match — `parseProbe` (`readiness.ts`) trims `wait.target` before
|
|
1726
|
+
// the worker keys on it, and the display/digest render the trimmed form (`p.target.trim()`,
|
|
1727
|
+
// deliveryGraphCompiler.ts:1476). Matching the RAW `node.wait.target` here let a padded fact
|
|
1728
|
+
// reference (`" open.pr "`) render/digest like the trimmed ref yet SKIP this binding, leaving the
|
|
1729
|
+
// runtime with the literal `open.pr` the wait can never resolve (issue #778 review — thread
|
|
1730
|
+
// deliveryGraphCompiler.ts:1476). Mirrors the connector's `payload.pr.trim()` normalisation.
|
|
1731
|
+
const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target.trim());
|
|
960
1732
|
if (boundTarget) {
|
|
961
1733
|
const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
|
|
962
1734
|
const probeRef = cfg("probe").slice(1);
|
|
@@ -1032,7 +1804,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1032
1804
|
* the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
|
|
1033
1805
|
* a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
|
|
1034
1806
|
* escalated outcome). */
|
|
1035
|
-
function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string
|
|
1807
|
+
function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displayName: string): string[] {
|
|
1036
1808
|
const el = w.element;
|
|
1037
1809
|
const node = w.node;
|
|
1038
1810
|
switch (node.kind) {
|
|
@@ -1042,14 +1814,23 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1042
1814
|
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1043
1815
|
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1044
1816
|
const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
|
|
1045
|
-
|
|
1817
|
+
// The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
|
|
1818
|
+
// routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
|
|
1819
|
+
// be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
|
|
1820
|
+
// timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
|
|
1821
|
+
// `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
|
|
1822
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName);
|
|
1046
1823
|
}
|
|
1047
1824
|
case "connector":
|
|
1048
|
-
|
|
1825
|
+
// TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
|
|
1826
|
+
// worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
|
|
1827
|
+
// `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
|
|
1828
|
+
// review — thread deliveryGraphCompiler.ts:1741).
|
|
1829
|
+
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName);
|
|
1049
1830
|
case "wait":
|
|
1050
|
-
return waitBodyLines(el, node);
|
|
1831
|
+
return waitBodyLines(el, node, displayName);
|
|
1051
1832
|
case "human":
|
|
1052
|
-
return humanBodyLines(el,
|
|
1833
|
+
return humanBodyLines(el, displayName);
|
|
1053
1834
|
default:
|
|
1054
1835
|
return assertNever(node, "innerBodyLines");
|
|
1055
1836
|
}
|
|
@@ -1129,6 +1910,7 @@ function serviceBodyLines(
|
|
|
1129
1910
|
descriptor: string,
|
|
1130
1911
|
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
1131
1912
|
taskHeaders: readonly string[] = [],
|
|
1913
|
+
taskName: string = nodeId,
|
|
1132
1914
|
): string[] {
|
|
1133
1915
|
const esc = escalationTaskElement(el);
|
|
1134
1916
|
const taskExt = [
|
|
@@ -1149,10 +1931,11 @@ function serviceBodyLines(
|
|
|
1149
1931
|
"nodeTimeout",
|
|
1150
1932
|
"; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
|
|
1151
1933
|
),
|
|
1934
|
+
{ displayName: taskName },
|
|
1152
1935
|
);
|
|
1153
1936
|
const head = [
|
|
1154
1937
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
1155
|
-
` <bpmn:serviceTask id="${el}_task" name="${escapeXml(
|
|
1938
|
+
` <bpmn:serviceTask id="${el}_task" name="${escapeXml(taskName)}">`,
|
|
1156
1939
|
...taskExt,
|
|
1157
1940
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
1158
1941
|
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
@@ -1188,7 +1971,7 @@ function serviceBodyLines(
|
|
|
1188
1971
|
// Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
|
|
1189
1972
|
// the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
|
|
1190
1973
|
// so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
|
|
1191
|
-
emits.length > 0 ? { resume: { kind: "agent" as const, emits } } :
|
|
1974
|
+
{ ...(emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : {}), displayName: taskName },
|
|
1192
1975
|
);
|
|
1193
1976
|
return [
|
|
1194
1977
|
...head,
|
|
@@ -1212,8 +1995,9 @@ function serviceBodyLines(
|
|
|
1212
1995
|
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
1213
1996
|
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
1214
1997
|
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
1215
|
-
function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }
|
|
1998
|
+
function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>, displayName: string): string[] {
|
|
1216
1999
|
const nodeId = node.id;
|
|
2000
|
+
const name = escapeXml(displayName);
|
|
1217
2001
|
const esc = escalationTaskElement(el);
|
|
1218
2002
|
const emits = normaliseEmits(node);
|
|
1219
2003
|
// `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
|
|
@@ -1221,7 +2005,14 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1221
2005
|
// stop (a documented sharp edge — the downstream side-effecting node then runs without the awaited
|
|
1222
2006
|
// fact). `fail` is rejected earlier at validation (blocked on engine terminate-end, #978), so it
|
|
1223
2007
|
// never reaches here.
|
|
1224
|
-
|
|
2008
|
+
//
|
|
2009
|
+
// NORMALISE (trim) `onTimeout` before the topology decision — `parseProbe` trims it
|
|
2010
|
+
// (`str(raw.onTimeout).trim()`) and the display/digest path uses `trimmedOrEmpty(p.onTimeout)`, so a
|
|
2011
|
+
// padded `" continue "` runs the SAME `continue` routing at the worker. Comparing the RAW value here
|
|
2012
|
+
// would emit the escalation branch for a `" continue "` the runtime treats as continue — the compiled
|
|
2013
|
+
// topology diverging from the requested (and digested) behaviour (issue #778 review — thread
|
|
2014
|
+
// deliveryGraphCompiler.ts:2012).
|
|
2015
|
+
const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
|
|
1225
2016
|
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
1226
2017
|
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
1227
2018
|
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
@@ -1233,7 +2024,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1233
2024
|
];
|
|
1234
2025
|
return [
|
|
1235
2026
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
1236
|
-
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${
|
|
2027
|
+
` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${name}">`,
|
|
1237
2028
|
" <bpmn:extensionElements>",
|
|
1238
2029
|
" <zeebe:ioMapping>",
|
|
1239
2030
|
' <zeebe:output source="=ready" target="ready" />',
|
|
@@ -1247,7 +2038,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1247
2038
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
1248
2039
|
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
1249
2040
|
` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
|
|
1250
|
-
` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${
|
|
2041
|
+
` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${name}">`,
|
|
1251
2042
|
" <bpmn:extensionElements>",
|
|
1252
2043
|
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
1253
2044
|
" <zeebe:properties>",
|
|
@@ -1280,7 +2071,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1280
2071
|
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
1281
2072
|
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
1282
2073
|
" </bpmn:boundaryEvent>",
|
|
1283
|
-
` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${
|
|
2074
|
+
` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${name}">`,
|
|
1284
2075
|
" <bpmn:extensionElements>",
|
|
1285
2076
|
` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
|
|
1286
2077
|
" <zeebe:properties>",
|
|
@@ -1307,7 +2098,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1307
2098
|
[`${el}_i4`],
|
|
1308
2099
|
`${el}_i5`,
|
|
1309
2100
|
waitEscalationContextFeel(nodeId),
|
|
1310
|
-
{ resume: { kind: node.kind, emits }, diagnosticInputs },
|
|
2101
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs, displayName },
|
|
1311
2102
|
)),
|
|
1312
2103
|
// On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
|
|
1313
2104
|
// human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
|
|
@@ -1328,13 +2119,13 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
1328
2119
|
* — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
|
|
1329
2120
|
* `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
|
|
1330
2121
|
* `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
|
|
1331
|
-
function humanBodyLines(el: string,
|
|
2122
|
+
function humanBodyLines(el: string, displayName: string): string[] {
|
|
1332
2123
|
const task = humanTaskElement(el);
|
|
1333
2124
|
const assignee =
|
|
1334
2125
|
'=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
|
|
1335
2126
|
return [
|
|
1336
2127
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
1337
|
-
` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(
|
|
2128
|
+
` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(displayName)}">`,
|
|
1338
2129
|
" <bpmn:extensionElements>",
|
|
1339
2130
|
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
1340
2131
|
" <zeebe:userTask />",
|
|
@@ -1389,6 +2180,7 @@ function escalationTaskLines(
|
|
|
1389
2180
|
opts?: {
|
|
1390
2181
|
resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
|
|
1391
2182
|
diagnosticInputs?: readonly { source: string; target: string }[];
|
|
2183
|
+
displayName?: string;
|
|
1392
2184
|
},
|
|
1393
2185
|
): string[] {
|
|
1394
2186
|
const emits = opts?.resume?.emits ?? [];
|
|
@@ -1424,8 +2216,12 @@ function escalationTaskLines(
|
|
|
1424
2216
|
);
|
|
1425
2217
|
}
|
|
1426
2218
|
}
|
|
2219
|
+
// The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
|
|
2220
|
+
// timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
|
|
2221
|
+
// `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
|
|
2222
|
+
const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
|
|
1427
2223
|
return [
|
|
1428
|
-
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(
|
|
2224
|
+
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
|
|
1429
2225
|
" <bpmn:extensionElements>",
|
|
1430
2226
|
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
1431
2227
|
" <zeebe:userTask />",
|
|
@@ -1472,9 +2268,10 @@ function flow(id: string, source: string, target: string): string {
|
|
|
1472
2268
|
return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
|
|
1473
2269
|
}
|
|
1474
2270
|
|
|
1475
|
-
/** Render a human-readable mermaid `flowchart` of the resolved graph — one
|
|
1476
|
-
*
|
|
1477
|
-
*
|
|
2271
|
+
/** Render a human-readable mermaid `flowchart` of the resolved graph — one box per node labelled with
|
|
2272
|
+
* the SAME descriptive {@link nodeDisplay} name the compiled BPMN uses (issue #778: one display source,
|
|
2273
|
+
* no `kind: id` drift between the diagram and the deployed model), one arrow per edge (labelled with the
|
|
2274
|
+
* referenced fact when qualified). Deterministic (nodes/edges already sorted). */
|
|
1478
2275
|
function renderMermaid(
|
|
1479
2276
|
graph: DeliveryGraph,
|
|
1480
2277
|
wirings: readonly NodeWiring[],
|
|
@@ -1484,7 +2281,7 @@ function renderMermaid(
|
|
|
1484
2281
|
const lines: string[] = ["flowchart TD"];
|
|
1485
2282
|
if (graph.name !== undefined) lines.push(` %% ${escapeMermaid(graph.name)}`);
|
|
1486
2283
|
for (const w of wirings) {
|
|
1487
|
-
lines.push(` ${w.element}["${escapeMermaid(
|
|
2284
|
+
lines.push(` ${w.element}["${escapeMermaid(nodeDisplay(w.node).name)}"]`);
|
|
1488
2285
|
}
|
|
1489
2286
|
for (const edge of edges) {
|
|
1490
2287
|
const from = mustGet(elementById, edge.fromNode);
|