@nanobpm/nano-workforce 0.200.9 → 0.201.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +188 -0
  3. package/app/agentCompletion.test.ts +52 -0
  4. package/app/agentCompletion.ts +20 -5
  5. package/app/contracts.ts +9 -0
  6. package/app/deliveryGraph.test.ts +261 -0
  7. package/app/deliveryGraph.ts +334 -1
  8. package/app/deliveryGraphCompiler.test.ts +1698 -39
  9. package/app/deliveryGraphCompiler.ts +1047 -149
  10. package/app/deliveryGraphDeploy.test.ts +472 -3
  11. package/app/deliveryGraphRun.ts +2 -2
  12. package/app/deliveryHuman.test.ts +59 -5
  13. package/app/deliveryHuman.ts +115 -3
  14. package/app/deliveryRunner.test.ts +3 -3
  15. package/app/deliveryRunner.ts +14 -5
  16. package/app/pullRequestReadModel.test.ts +1 -1
  17. package/app/redactText.ts +88 -0
  18. package/app/roundChurn.test.ts +388 -0
  19. package/app/roundChurn.ts +265 -0
  20. package/app/roundProgress.test.ts +224 -1
  21. package/app/service.test.ts +7 -0
  22. package/app/service.ts +28 -2
  23. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  24. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  25. package/docs/agent-guide.md +10 -7
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +580 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/workers/progress-check/worker.ts +98 -1
  33. package/workers/readiness-probe/worker.test.ts +72 -1
  34. package/workers/readiness-probe/worker.ts +39 -0
@@ -32,6 +32,7 @@
32
32
  // binds and pins exactly the value the human handed forward.
33
33
  import type { DeliveryFact, DeliveryNodeHuman } from "../nano-generated/api-io.d.ts";
34
34
  import { DELIVERY_FACT_TYPES, type DeliveryFactType, FACT_NAME_MAX_LENGTH, FACT_NAME_PATTERN } from "./deliveryGraph.ts";
35
+ import { redactFreeText } from "./redactText.ts";
35
36
 
36
37
  /** The single BPMN `bpmn:userTask` element id every `human` delivery-graph node schedules its work
37
38
  * as (`resources/processes/delivery-human.bpmn`). One reusable engine-native body, instantiated once
@@ -54,6 +55,77 @@ export function isDeliveryHumanElement(elementId: string): boolean {
54
55
  return elementId === DELIVERY_HUMAN_ELEMENT || elementId.startsWith(`${DELIVERY_HUMAN_ELEMENT}__`);
55
56
  }
56
57
 
58
+ /** The id suffix the S4 compiler appends for a service node's bounded-timeout escalation TWIN user task
59
+ * (`delivery-human-task__<el>__esc`). */
60
+ export const DELIVERY_ESCALATION_TWIN_SUFFIX = "__esc";
61
+
62
+ /** The id suffix for an `agent` node's producer-contract escalation TWIN (`…__contract`). */
63
+ export const DELIVERY_CONTRACT_TWIN_SUFFIX = "__contract";
64
+
65
+ /** The KIND marker the S4 compiler appends to a `wait` gate's bounded-timeout escalation twin
66
+ * (`delivery-human-task__<el>__esc__wait`). A wait-gate escalation has NO retry semantics — its
67
+ * resolution is "supply the awaited value and continue", never "re-run the probe loop" — so the
68
+ * compiler renders the select-less {@link GENERIC_HUMAN_FORM} for it, NOT the retry-capable
69
+ * {@link ESCALATION_FORM} a service-node `__esc`/`__contract` twin renders. The bare `__esc` suffix is
70
+ * shared by both kinds, so a form inferred from the suffix ALONE misclassifies the wait gate as
71
+ * retryable (the #461 Tasks-inbox `form_key` fallback then renders a bogus "Retry this step" that the
72
+ * wait process silently ignores). Stamping the kind into the id lets {@link escalationFormId} DERIVE
73
+ * the compiled form from the id — no separate node-kind registry to drift (PR #863 review,
74
+ * app/agentCompletion.ts:186). */
75
+ export const DELIVERY_WAIT_ESCALATION_KIND_MARKER = "__wait";
76
+
77
+ /** The full id suffix of a `wait` gate's bounded-timeout escalation twin (`__esc` + the `__wait` kind
78
+ * marker). */
79
+ export const DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX = `${DELIVERY_ESCALATION_TWIN_SUFFIX}${DELIVERY_WAIT_ESCALATION_KIND_MARKER}`;
80
+
81
+ /** True for a delivery-graph SERVICE-node escalation TWIN user task — the bounded-timeout `__esc` twin
82
+ * or an agent's producer-contract `__contract` twin — as opposed to a plain per-node human task
83
+ * (`delivery-human-task__<el>`). Unlike that per-node human task, which renders a DIFFERENT form per
84
+ * node (and so has no single static completion contract — left unvalidated, `ESCALATION_FORM_BY_ELEMENT`
85
+ * deliberately omits it), a twin ALWAYS renders a FIXED escalation form — the retry-capable
86
+ * {@link ESCALATION_FORM} (a strict superset of {@link GENERIC_HUMAN_FORM}: same `value`, an optional
87
+ * note field (`escalationNote` on the escalation form, `note` on the generic human form), PLUS
88
+ * the `decision` select) or the select-less generic form — so its completion variables CAN be validated
89
+ * against `ESCALATION_FORM`'s contract (no required fields, so the generic-form twins never false-reject;
90
+ * only a present-but-invalid `decision` is rejected). Matched on the convention suffix — single source of
91
+ * truth with the compiler's `escalationTaskElement`/`contractEscalationTaskElement` builders — requiring
92
+ * a NON-EMPTY `<el>` between prefix and suffix so the bare per-node base of a node literally named
93
+ * `esc`/`contract` (`delivery-human-task__esc`) is not misread as a twin. (A node id MAY itself end in
94
+ * `__esc` — `NODE_ID_PATTERN` allows it — so the twin of node `foo` and the base of a node named
95
+ * `foo__esc` collide on one id; validating that rare base against the superset escalation form is
96
+ * harmless, since the form has no required fields and only constrains a `decision` such a base never
97
+ * sets to an out-of-range value through its own select-less form.) */
98
+ export function isDeliveryEscalationTwin(elementId: string): boolean {
99
+ const prefix = `${DELIVERY_HUMAN_ELEMENT}__`;
100
+ if (!elementId.startsWith(prefix)) return false;
101
+ for (const suffix of [DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX, DELIVERY_ESCALATION_TWIN_SUFFIX, DELIVERY_CONTRACT_TWIN_SUFFIX]) {
102
+ if (elementId.endsWith(suffix) && elementId.length > prefix.length + suffix.length) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** True for a `wait` gate's bounded-timeout escalation twin specifically (`…__esc__wait`) — the twin
108
+ * kind that renders the select-less {@link GENERIC_HUMAN_FORM} (no retry path), as opposed to a
109
+ * service-node `__esc`/`__contract` twin, which renders the retry-capable {@link ESCALATION_FORM}.
110
+ * This is the discriminator {@link escalationFormId} uses to DERIVE the compiled form from the id. */
111
+ export function isDeliveryWaitEscalationTwin(elementId: string): boolean {
112
+ const prefix = `${DELIVERY_HUMAN_ELEMENT}__`;
113
+ return (
114
+ elementId.startsWith(prefix) &&
115
+ elementId.endsWith(DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX) &&
116
+ elementId.length > prefix.length + DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX.length
117
+ );
118
+ }
119
+
120
+ /** Strip the trailing escalation-twin suffix (`__esc`, the wait-gate `__esc__wait`, or `__contract`)
121
+ * to recover the base per-node element id (`delivery-human-task__<el>`). The `__esc__wait` kind marker
122
+ * MUST be stripped before the bare `__esc` (it ends in `__esc`, so stripping `__esc` first would leave
123
+ * a dangling `__wait`). A non-twin id is returned unchanged. Single source of truth for the strip so
124
+ * every "resolve the twin to its base node" lookup agrees on the suffix set. */
125
+ export function stripDeliveryEscalationTwinSuffix(elementId: string): string {
126
+ return elementId.replace(/__(esc__wait|esc|contract)$/, "");
127
+ }
128
+
57
129
  /** The read-model "Decision context" for a parked delivery-graph `human` node (issue #772). The Tasks
58
130
  * surface renders the deployed `.form` against EMPTY data (it seeds no task-local variables), so the
59
131
  * node's instruction cannot reach the operator through the form's readonly `prompt` field — it must
@@ -76,11 +148,45 @@ export function isDeliveryHumanElement(elementId: string): boolean {
76
148
  export function deliveryHumanContextQuestion(
77
149
  humanLabels: Record<string, string> | undefined,
78
150
  elementId: string,
151
+ livePrompt?: string | null,
79
152
  ): string {
80
153
  const labels = humanLabels ?? {};
81
- const base = elementId.replace(/__esc$/, "");
154
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
82
155
  const label = (labels[elementId] ?? labels[base] ?? "").trim();
83
- return label || "A scheduled delivery-graph step is waiting to be completed.";
156
+ return label || (livePrompt ?? "").trim() || "A scheduled delivery-graph step is waiting to be completed.";
157
+ }
158
+
159
+ /** Whether a parked delivery task needs its LIVE engine prompt for the "Decision context": an
160
+ * `__esc`/`__contract` escalation twin of a bounded agent/wait/connector node has no stamped
161
+ * `human_labels` entry, so without the compiler's per-task `prompt` (node, reported status, missing
162
+ * emits, the agent's own report/question/transcript, and how to resolve) the operator saw only the
163
+ * static fallback — the "no actionable information" escalations of instance 171774. A real human
164
+ * node (or its `__esc` twin) keeps its authored instruction label. */
165
+ export function needsLiveDeliveryPrompt(humanLabels: Record<string, string> | undefined, elementId: string): boolean {
166
+ if (!/__(esc__wait|esc|contract)$/.test(elementId)) return false;
167
+ const labels = humanLabels ?? {};
168
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
169
+ return !(labels[elementId] ?? labels[base] ?? "").trim();
170
+ }
171
+
172
+ /** Decode a `prompt` variable as the engine's variable search reports it (a JSON-encoded value) into
173
+ * plain text; `null` for an absent, non-string, or truncated preview (never show a clipped prompt as
174
+ * if it were whole — the caller falls back).
175
+ *
176
+ * The decoded text is REDACTED ({@link redactFreeText}) before it is returned: the escalation prompt
177
+ * embeds agent-controlled `summary`/`question`/`error`/`transcriptUrl` (the compiler's report
178
+ * sentence), and the caller persists the result as the operator-facing `user_tasks.question`. An
179
+ * agent report can carry a token-bearing URL or a credential copied into its report, so this path
180
+ * redacts exactly like the static `human_labels` instruction (which `buildHumanLabels` runs through
181
+ * `redactFreeText`, issue #778) — never store a live prompt raw (issue #863 review). */
182
+ export function decodeLivePrompt(row: { value: string; isTruncated?: boolean } | undefined): string | null {
183
+ if (!row || row.isTruncated) return null;
184
+ try {
185
+ const v: unknown = JSON.parse(row.value);
186
+ return typeof v === "string" && v.trim() ? redactFreeText(v.trim()) : null;
187
+ } catch {
188
+ return null;
189
+ }
84
190
  }
85
191
 
86
192
  /** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
@@ -96,7 +202,7 @@ export function deliveryHumanContextUrl(
96
202
  elementId: string,
97
203
  ): string | null {
98
204
  const labels = humanLabels ?? {};
99
- const base = elementId.replace(/__esc$/, "");
205
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
100
206
  const instruction = labels[elementId] ?? labels[base] ?? "";
101
207
  const url = firstHttpUrl(instruction);
102
208
  // The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
@@ -179,6 +285,12 @@ export function firstHttpUrl(text: string | undefined | null): string | null {
179
285
  * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
180
286
  export const GENERIC_HUMAN_FORM = "delivery-human-generic";
181
287
 
288
+ /** The bounded service-node ESCALATION form (retry-node resolution): like the generic form plus the
289
+ * `decision` Resolution select (Continue / Retry this step). Kept SEPARATE from {@link GENERIC_HUMAN_FORM}
290
+ * — that form is shared with plain scheduled human steps and wait-gate escalations, which have no
291
+ * retry semantics and never read `decision`, so the select must not render there. */
292
+ export const ESCALATION_FORM = "delivery-escalation";
293
+
182
294
  /** The "click done" category form: a degenerate no-emit acknowledgement ("now do X" → done). */
183
295
  export const HUMAN_ACK_FORM = "delivery-human-ack";
184
296
 
@@ -238,12 +238,12 @@ test("producer contract required-emit subset (#761): a routing-only emit is NOT
238
238
  const graph: DeliveryGraph = {
239
239
  name: "routing-only producer",
240
240
  nodes: [
241
- { id: "classify", kind: "agent", agent: { jobType: "senior:feature", prompt: "classify it" }, emits: [{ name: "decision", type: "string" }] },
241
+ { id: "classify", kind: "agent", agent: { jobType: "senior:feature", prompt: "classify it" }, emits: [{ name: "verdict", type: "string" }] },
242
242
  { id: "migrate", kind: "connector", connector: { target: "npm:install", dedupeKey: "m-1" } },
243
243
  { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "r-1" } },
244
244
  ],
245
245
  edges: [
246
- { from: "classify", to: "migrate", when: "classify.decision", equals: "breaking" },
246
+ { from: "classify", to: "migrate", when: "classify.verdict", equals: "breaking" },
247
247
  { from: "classify", to: "release", default: true },
248
248
  ],
249
249
  };
@@ -257,7 +257,7 @@ test("producer contract required-emit subset (#761): a routing-only emit is NOT
257
257
  assert(!prompt.includes("populate each of these top-level fields"), "no required-emit sentence is rendered when every emit is routing-only");
258
258
  // The classifier-emit contract STILL tells the agent to return the routing fact (and omit if undecidable).
259
259
  assert(prompt.includes("Classifier emit contract"), "the classifier-emit contract is present");
260
- assert(prompt.includes("`decision`"), "the routing fact is still named by the classifier-emit contract");
260
+ assert(prompt.includes("`verdict`"), "the routing fact is still named by the classifier-emit contract");
261
261
  });
262
262
 
263
263
 
@@ -20,7 +20,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
21
  import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
22
22
  import { isPlausibleBranchName } from "./baseBranch.ts";
23
- import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, redactFreeText } from "./deliveryGraphCompiler.ts";
23
+ import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, DELIVERY_NODE_DEFAULT_TIMEOUT, redactFreeText } from "./deliveryGraphCompiler.ts";
24
24
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
25
25
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
26
26
  import { isoDuration } from "./reviewWait.ts";
@@ -97,7 +97,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
97
97
  }
98
98
 
99
99
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
100
- nodeTimeout: "PT1H",
100
+ nodeTimeout: DELIVERY_NODE_DEFAULT_TIMEOUT,
101
101
  probeTimeout: "PT30M",
102
102
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
103
103
  escalationSlaTimeout: "P1D",
@@ -127,6 +127,11 @@ export interface PreparedDeliveryGraph {
127
127
  * `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
128
128
  * handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
129
129
  runKey: string;
130
+ /** The resolved run-level node SLA (`isoDuration(options.nodeTimeout)`), surfaced so `runDeliveryGraph`
131
+ * seeds it as the run-root `runNodeTimeout` variable — the fallback a node's bounded-timeout
132
+ * ioMapping reads when its per-node `nodeInputs.<el>.timeout` is null, so a node released with no
133
+ * per-node timeout gets a real SLA instead of a zero-length timer that fires instantly (issue #872). */
134
+ runNodeTimeout: string;
130
135
  }
131
136
 
132
137
  export type PrepareDeliveryResult =
@@ -198,7 +203,7 @@ export async function prepareDeliveryGraph(
198
203
  if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
199
204
  nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
200
205
  }
201
- return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
206
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout: timeouts.nodeTimeout } };
202
207
  }
203
208
 
204
209
  /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
@@ -212,7 +217,7 @@ export async function runDeliveryGraph(
212
217
  ): Promise<RunDeliveryResult> {
213
218
  const prep = await prepareDeliveryGraph(graph, options);
214
219
  if (!prep.ok) return prep;
215
- const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
220
+ const { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout } = prep.prepared;
216
221
 
217
222
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
218
223
  // Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
@@ -235,6 +240,10 @@ export async function runDeliveryGraph(
235
240
  variables: {
236
241
  nodeInputs,
237
242
  runKey,
243
+ // Run-level node SLA fallback (issue #872): the bounded-timeout ioMapping reads this when a node's
244
+ // per-node `nodeInputs.<el>.timeout` is null, so a node released without a per-node timeout gets a
245
+ // real SLA instead of a null `=nodeTimeout` that fires the boundary timer instantly.
246
+ runNodeTimeout,
238
247
  // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
239
248
  // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
240
249
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
@@ -250,7 +259,7 @@ export async function runDeliveryGraph(
250
259
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
251
260
  return {
252
261
  ok: true,
253
- handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
262
+ handle: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout, processInstanceKey: String(processInstanceKey) },
254
263
  };
255
264
  }
256
265
 
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "104_pull_requests_read_model_progress_idempotency.sql";
36
+ const READ_MODEL_MIGRATION = "118_pull_requests_read_model_churn_escalated_round.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
@@ -0,0 +1,88 @@
1
+ // Free-form-prose URL-credential redaction (`redactFreeText`) lives in this LOW-LEVEL helper module so
2
+ // both the delivery-graph compiler (`deliveryGraphCompiler.ts`, which embeds redacted prompts into the
3
+ // generated BPMN display name/documentation) AND `deliveryHuman.ts` (which redacts a decoded live
4
+ // prompt / human labels) can import it WITHOUT a cycle. Previously `redactFreeText` was defined in the
5
+ // compiler and `deliveryHuman.ts` imported the compiler solely for it — a direct `compiler ⇄ human`
6
+ // import cycle that reversed `deliveryHuman.ts`'s documented pure-helper layering and made module init
7
+ // depend on ESM cycle ordering (PR #863 review — thread deliveryGraphCompiler.ts / deliveryHuman.ts).
8
+ // This module depends only on the character-class strip (`deliveryGraph.ts`) and the canonical URL
9
+ // redactors (`readiness.ts`), both strictly lower layers, so nothing here can import back.
10
+
11
+ import { stripXmlInvalidChars } from "./deliveryGraph.ts";
12
+ import { redactEmbeddedCredential, redactString } from "./readiness.ts";
13
+
14
+ /** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
15
+ * `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
16
+ * truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
17
+ * surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
18
+ * `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
19
+ * punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
20
+ * a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
21
+ * the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
22
+ * reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
23
+ * and total. */
24
+ export function redactFreeText(value: string): string {
25
+ // Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
26
+ // string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
27
+ // the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
28
+ // `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
29
+ // connector/probe strip-before-classify fix).
30
+ const cleaned = stripXmlInvalidChars(value);
31
+ // Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
32
+ // (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
33
+ // spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
34
+ // collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
35
+ // its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
36
+ // CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
37
+ // credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
38
+ // (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
39
+ // stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
40
+ // span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
41
+ // SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
42
+ // visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
43
+ // marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
44
+ // the query/fragment belt below.
45
+ const credStripped = redactEmbeddedCredential(cleaned);
46
+ // Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
47
+ // `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
48
+ // each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
49
+ // CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
50
+ // past the break is indistinguishable from a split value, so we never keep the far side (issue #778
51
+ // review — thread deliveryGraphCompiler.ts:1115).
52
+ const belted = redactQueryFragmentSpans(credStripped);
53
+ return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
54
+ }
55
+
56
+ /** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
57
+ * whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
58
+ * cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
59
+ * already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
60
+ * belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
61
+ * `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
62
+ * indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
63
+ * prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
64
+ * single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
65
+ * regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
66
+ * #778 review). Deterministic and total. */
67
+ function redactQueryFragmentSpans(text: string): string {
68
+ let out = "";
69
+ let i = 0;
70
+ for (;;) {
71
+ const start = text.indexOf("//", i);
72
+ if (start < 0) return out + text.slice(i);
73
+ out += text.slice(i, start);
74
+ let end = start;
75
+ while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
76
+ const span = text.slice(start, end);
77
+ const qMark = span.indexOf("?");
78
+ const hMark = span.indexOf("#");
79
+ const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
80
+ // CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
81
+ // (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
82
+ // INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
83
+ // prompt still reaches the runtime job input unmodified (issue #778 review — thread
84
+ // deliveryGraphCompiler.ts:1115).
85
+ out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
86
+ i = end;
87
+ }
88
+ }