@nanobpm/nano-workforce 0.201.0 → 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.
- package/CHANGELOG.md +6 -0
- package/SPEC.md +153 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +251 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1345 -30
- package/app/deliveryGraphCompiler.ts +882 -240
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/redactText.ts +88 -0
- package/app/service.ts +16 -2
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +11 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
package/app/deliveryHuman.ts
CHANGED
|
@@ -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
|
|
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
|
|
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: "
|
|
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.
|
|
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("`
|
|
260
|
+
assert(prompt.includes("`verdict`"), "the routing fact is still named by the classifier-emit contract");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
|
|
@@ -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
|
+
}
|
package/app/service.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./deliver
|
|
|
34
34
|
import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
35
35
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
36
36
|
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
|
|
37
|
-
import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
37
|
+
import { decodeLivePrompt, deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement, needsLiveDeliveryPrompt } from "./deliveryHuman.ts";
|
|
38
38
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
39
39
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
40
40
|
import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
|
|
@@ -3316,7 +3316,21 @@ export async function pollUserTasks(
|
|
|
3316
3316
|
// null), so this is the only link a delivery human task can offer.
|
|
3317
3317
|
let deliverySubjectUrl: string | null = null;
|
|
3318
3318
|
if (question === null && isDeliveryHumanElement(elementId)) {
|
|
3319
|
-
|
|
3319
|
+
let livePrompt: string | null = null;
|
|
3320
|
+
if (needsLiveDeliveryPrompt(subj?.humanLabels, elementId)) {
|
|
3321
|
+
// An agent/wait/connector escalation twin: read the task's OWN compiler-rendered `prompt`, local
|
|
3322
|
+
// to the user task's element instance (fail-open to the static fallback on any read error).
|
|
3323
|
+
try {
|
|
3324
|
+
const [ei] = await engine.searchElementInstances({ processInstanceKey, elementId, state: "ACTIVE" });
|
|
3325
|
+
if (ei) {
|
|
3326
|
+
const [row] = await engine.searchVariables({ scopeKey: String(ei.elementInstanceKey), name: "prompt" });
|
|
3327
|
+
livePrompt = decodeLivePrompt(row);
|
|
3328
|
+
}
|
|
3329
|
+
} catch (err) {
|
|
3330
|
+
console.error(`[poller] delivery escalation prompt (${processInstanceKey}/${elementId}): ${err}`);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
question = deliveryHumanContextQuestion(subj?.humanLabels, elementId, livePrompt);
|
|
3320
3334
|
deliverySubjectUrl = deliveryHumanContextUrl(subj?.humanLabels, elementId);
|
|
3321
3335
|
}
|
|
3322
3336
|
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? deliverySubjectUrl, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
@@ -21,6 +21,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
|
21
21
|
import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
|
|
22
22
|
import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
|
|
23
23
|
import { pollDeliveryGraphPhase } from "../app/service.ts";
|
|
24
|
+
import { isDeliveryEscalationTwin } from "../app/deliveryHuman.ts";
|
|
24
25
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
25
26
|
|
|
26
27
|
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
@@ -129,7 +130,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
|
|
|
129
130
|
|
|
130
131
|
// ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
|
|
131
132
|
const open = await app.engine.searchUserTasks({ state: "CREATED" });
|
|
132
|
-
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId
|
|
133
|
+
const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
|
|
133
134
|
assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
|
|
134
135
|
await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
|
|
135
136
|
await app.settle();
|