@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/deliveryGraph.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
|
|
20
20
|
// caller can point the author straight at the offending input.
|
|
21
21
|
|
|
22
|
+
import { TRANSCRIPT_URL_BASE_VAR } from "./agentic/transcript-url.ts";
|
|
22
23
|
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
23
24
|
import { isEnvKey } from "./contracts.ts";
|
|
24
25
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
@@ -107,10 +108,13 @@ export type DeliveryGraphErrorCode =
|
|
|
107
108
|
| "credential-in-job-type"
|
|
108
109
|
| "embedded-url-in-job-type"
|
|
109
110
|
| "scheme-relative-url-in-job-type"
|
|
111
|
+
| "invalid-form-key"
|
|
112
|
+
| "credential-in-form-key"
|
|
110
113
|
| "invalid-credential-env"
|
|
111
114
|
| "invalid-backoff"
|
|
112
115
|
| "unbound-pr"
|
|
113
|
-
| "partial-scope-close"
|
|
116
|
+
| "partial-scope-close"
|
|
117
|
+
| "human-unroutable-emits";
|
|
114
118
|
|
|
115
119
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
116
120
|
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
@@ -259,6 +263,216 @@ function isDeliveryFactType(type: unknown): type is DeliveryFactType {
|
|
|
259
263
|
export const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
260
264
|
export const FACT_NAME_MAX_LENGTH = 128;
|
|
261
265
|
|
|
266
|
+
/** The flat result variables a fleet agent worker returns on job completion — the CANONICAL node-local
|
|
267
|
+
* agent result contract. Every field a `resources/prompts/*.md` output contract tells an agent to
|
|
268
|
+
* return is listed here: the blocked-outcome fallback `{status, summary, question}`, the free-form
|
|
269
|
+
* result fields agents commonly emit, the optional structured `delta` an implementer reports
|
|
270
|
+
* (resources/prompts/feature.md), the PR identity fields, and the review/audit contract fields
|
|
271
|
+
* (`plan-review.md` returns `approved`/`findings`; `conformance.md` returns `commentUrl`,
|
|
272
|
+
* `hasDeviations`, and the per-slice/deviation counts). Nano propagates job-completion variables to the
|
|
273
|
+
* NEAREST scope that defines each name, else the ROOT — so the compiler declares each `null` on the
|
|
274
|
+
* node's subProcess (and the retry reset clears it) to keep a node's report node-local; a field omitted
|
|
275
|
+
* here leaks to the shared root, where a parallel node overwrites it and a retry leaves it stale
|
|
276
|
+
* (issue #863 "Previously missed"). Defined HERE, not in the compiler, so the validator's
|
|
277
|
+
* reserved-fact-name set can derive from it without an import cycle.
|
|
278
|
+
*
|
|
279
|
+
* NOTE: these are NOT reserved as emit names — an agent MAY legitimately emit a fact named `pr` (the
|
|
280
|
+
* canonical `agent → connector[converge] → wait[pr]` shape emits the PR it opened under `pr`). The
|
|
281
|
+
* worker's result write and the emit publish the SAME node-local value, so they agree; the retry reset
|
|
282
|
+
* clearing both is correct (the retry must re-produce the fact). Only the escalation controls and the
|
|
283
|
+
* config variables the reset would wrongly null are reserved — see {@link reservedDeliveryFactNames}. */
|
|
284
|
+
export const AGENT_RESULT_LOCAL_VARS = [
|
|
285
|
+
"status",
|
|
286
|
+
"summary",
|
|
287
|
+
"question",
|
|
288
|
+
"output",
|
|
289
|
+
"error",
|
|
290
|
+
"pr",
|
|
291
|
+
"prUrl",
|
|
292
|
+
"pullRequest",
|
|
293
|
+
"branch",
|
|
294
|
+
"commits",
|
|
295
|
+
"exitCode",
|
|
296
|
+
"next_steps",
|
|
297
|
+
"issue",
|
|
298
|
+
"completed",
|
|
299
|
+
"pushed",
|
|
300
|
+
"truncated",
|
|
301
|
+
"agentCheckpoint",
|
|
302
|
+
"transcriptUrl",
|
|
303
|
+
"delta",
|
|
304
|
+
"approved",
|
|
305
|
+
"findings",
|
|
306
|
+
"commentUrl",
|
|
307
|
+
"hasDeviations",
|
|
308
|
+
"slicesMet",
|
|
309
|
+
"slicesReduced",
|
|
310
|
+
"slicesNotVerified",
|
|
311
|
+
"deviationsRaised",
|
|
312
|
+
"deviationsUnraised",
|
|
313
|
+
// The remaining built-in prompt result contracts (issue #863 review — thread r4181321969). Each is a
|
|
314
|
+
// documented output of a `resources/prompts/*.md` agent that a delivery node may run; omitted here it
|
|
315
|
+
// would write at the shared ROOT, where a parallel node overwrites it and a retry retains a stale
|
|
316
|
+
// value:
|
|
317
|
+
// • `dependsOn` — fix-ci.md / rebase.md: the PR(s) that must merge first (`waiting-on-pr`).
|
|
318
|
+
// • `adversarialFindings` / `adversarialSummary` — adversarial-review.md.
|
|
319
|
+
// • `scopeBlocked` / `scopeBlockReason` — scope-classify.md.
|
|
320
|
+
// • `tasks` — plan.md (the decomposed task list).
|
|
321
|
+
// • `result` / `conflicts` / `failing` — trial-merge.md.
|
|
322
|
+
"dependsOn",
|
|
323
|
+
"adversarialFindings",
|
|
324
|
+
"adversarialSummary",
|
|
325
|
+
"scopeBlocked",
|
|
326
|
+
"scopeBlockReason",
|
|
327
|
+
"tasks",
|
|
328
|
+
"result",
|
|
329
|
+
"conflicts",
|
|
330
|
+
"failing",
|
|
331
|
+
// `note` — plan.md's free-text operator note (returned alongside `{ tasks: [] }`). It MUST be
|
|
332
|
+
// node-local: left at the shared root, a parallel node's `note` overwrites it. The escalation
|
|
333
|
+
// retry-guidance control is the DISTINCT `escalationNote` (ESCALATION_LOCAL_VARS) precisely so this
|
|
334
|
+
// worker result can never be read back as operator input (PR #863 "Previously missed").
|
|
335
|
+
"note",
|
|
336
|
+
] as const;
|
|
337
|
+
|
|
338
|
+
/** The fixed result metadata the delivery-connector worker returns on every job completion
|
|
339
|
+
* (`connectorOutcome`/`connectorDedupeKey`/`connectorDetail` — see `dispatchConnector` in
|
|
340
|
+
* app/deliveryConnector.ts). Like the agent result set, these are declared node-local and cleared on
|
|
341
|
+
* retry so two parallel connectors never overwrite one shared root result. Defined HERE so the
|
|
342
|
+
* validator's reserved set can derive from it without a cycle. */
|
|
343
|
+
export const CONNECTOR_RESULT_LOCAL_VARS = [
|
|
344
|
+
"connectorOutcome",
|
|
345
|
+
"connectorDedupeKey",
|
|
346
|
+
"connectorDetail",
|
|
347
|
+
] as const;
|
|
348
|
+
|
|
349
|
+
/** The human-node scratch a `human` subProcess writes/reads while the operator completes (or times out
|
|
350
|
+
* of) its user task — the emit scratch the task output mapping captures (`humanEmitValue`/
|
|
351
|
+
* `humanEmitArtifact`), the task's fixed outcome/note (`humanOutcome`/`humanNote`), and the form
|
|
352
|
+
* controls the output mapping SELECTS from (`value`/`resolvedArtifact`/`note`). None of these is
|
|
353
|
+
* node-unique, so left at the shared ROOT they cross-publish between parallel human nodes exactly as an
|
|
354
|
+
* un-localised agent/connector result once did (PR #863 review, thread r4199949849 "Isolate human
|
|
355
|
+
* subprocess scratch state"): if human A completes while human B times out WITHOUT producing a value,
|
|
356
|
+
* B's subProcess output reads A's root `humanEmitValue`/`humanEmitArtifact` and publishes it as B's own
|
|
357
|
+
* `<el>_<fact>`; likewise a B that completes on a blank form reads A's root `value`/`resolvedArtifact`.
|
|
358
|
+
* Declared node-local on each human subProcess (seeded `=null`, like the agent/connector result sets)
|
|
359
|
+
* so Nano's nearest-scope propagation lands each human's capture inside its OWN subProcess. The
|
|
360
|
+
* node's explicit fact-named control (`<factName>`, read first by a bespoke single-emit form) is
|
|
361
|
+
* localised ALONGSIDE this set from the node's declared emits, since it is dynamic per node. Defined
|
|
362
|
+
* HERE (the validator's home) so the reserved set stays derivable without a cycle. */
|
|
363
|
+
export const HUMAN_RESULT_LOCAL_VARS = [
|
|
364
|
+
"humanEmitValue",
|
|
365
|
+
"humanEmitArtifact",
|
|
366
|
+
"humanOutcome",
|
|
367
|
+
"humanNote",
|
|
368
|
+
"value",
|
|
369
|
+
"resolvedArtifact",
|
|
370
|
+
"note",
|
|
371
|
+
] as const;
|
|
372
|
+
|
|
373
|
+
/** The escalation-completion variable that chooses how a parked `agent`/`connector` node resumes
|
|
374
|
+
* (retry-node resolution): completing a `__esc` / `__contract` escalation with `{ decision: "retry" }`
|
|
375
|
+
* re-runs the node's job; any other value — or none — continues past it. Defined HERE (the validator's
|
|
376
|
+
* home) so {@link ESCALATION_LOCAL_VARS} derives from it and the compiler imports the ONE source —
|
|
377
|
+
* never a second hardcoded `"decision"` that could drift. */
|
|
378
|
+
export const ESCALATION_DECISION_VAR = "decision";
|
|
379
|
+
export const ESCALATION_DECISION_RETRY = "retry";
|
|
380
|
+
|
|
381
|
+
/** The escalation-completion variable that carries the operator's optional retry-guidance note
|
|
382
|
+
* (retry-node resolution): on `decision: "retry"` the retry reset appends it to the agent's prompt as
|
|
383
|
+
* "Operator guidance for this retry: …". Named `escalationNote` — NOT the plain `note` — because the
|
|
384
|
+
* escalation form's controls live in the SAME node-local subProcess scope a worker's job-completion
|
|
385
|
+
* result propagates into, and `note` is a documented WORKER RESULT field (`resources/prompts/plan.md`
|
|
386
|
+
* returns one): an operator who retries WITHOUT a note (the form permits omitting it) would otherwise
|
|
387
|
+
* have the previous attempt's worker-produced `note` fed back to the agent as if it were operator
|
|
388
|
+
* guidance (PR #863 Copilot "Previously missed", deliveryGraphCompiler.ts:2156). The plain `note`
|
|
389
|
+
* name is instead a node-local WORKER RESULT field ({@link AGENT_RESULT_LOCAL_VARS}), so no emit or
|
|
390
|
+
* escalation control can alias it. Defined HERE (the validator's home) so {@link ESCALATION_LOCAL_VARS}
|
|
391
|
+
* derives from it and the compiler imports the ONE source — never a second hardcoded `"escalationNote"`
|
|
392
|
+
* that could drift. */
|
|
393
|
+
export const ESCALATION_NOTE_VAR = "escalationNote";
|
|
394
|
+
|
|
395
|
+
/** The variables an escalation completion writes (the {@link ESCALATION_DECISION_VAR}, plus the
|
|
396
|
+
* escalation form's `value` + {@link ESCALATION_NOTE_VAR}). They are declared node-LOCAL on the node's
|
|
397
|
+
* subProcess so a completion never leaks into the shared root scope, where a sibling node's escalation
|
|
398
|
+
* would read a stale value. Defined HERE so the validator's reserved set can derive from it without a
|
|
399
|
+
* cycle. */
|
|
400
|
+
export const ESCALATION_LOCAL_VARS = [ESCALATION_DECISION_VAR, "value", ESCALATION_NOTE_VAR] as const;
|
|
401
|
+
|
|
402
|
+
/** The subProcess **config** variable names an `agent` node's compiled `ioMappingLines` seeds from the
|
|
403
|
+
* runner's `nodeInputs.<el>`. These live in the SAME node-local subProcess scope the emit's source
|
|
404
|
+
* variable occupies (a service-node fact's source is the fact's own name), so an emit named after one
|
|
405
|
+
* collides with the node's own configuration. */
|
|
406
|
+
export const AGENT_CONFIG_VARS = ["jobType", "appendPrompt", "nodeTimeout", TRANSCRIPT_URL_BASE_VAR] as const;
|
|
407
|
+
|
|
408
|
+
/** The subProcess **config** variables a `connector` node seeds (see {@link AGENT_CONFIG_VARS}). */
|
|
409
|
+
export const CONNECTOR_CONFIG_VARS = ["target", "dedupeKey", "payload", "nodeTimeout"] as const;
|
|
410
|
+
|
|
411
|
+
/** The subProcess **config** variables a `wait` node seeds. A `wait` emit's source is NOT the fact's own
|
|
412
|
+
* name ({@link factSourceVar} maps it to `mergedSha`/`prCount`/`resolvedArtifact`/`detail`), so these do
|
|
413
|
+
* NOT collide with a wait emit — listed for completeness/derivation, not reserved against a wait emit. */
|
|
414
|
+
export const WAIT_CONFIG_VARS = ["gateKey", "probe", "probeTimeout", "probePollEvery"] as const;
|
|
415
|
+
|
|
416
|
+
/** The subProcess **config** variables a `human` node seeds. A `human` emit's source is NOT the fact's
|
|
417
|
+
* own name ({@link factSourceVar} maps it to `humanEmitValue`/`humanEmitArtifact`), so these do NOT
|
|
418
|
+
* collide with a human emit — listed for completeness/derivation, not reserved against a human emit. */
|
|
419
|
+
export const HUMAN_CONFIG_VARS = [
|
|
420
|
+
"escalationSlaTimeout",
|
|
421
|
+
"escalationAssignee",
|
|
422
|
+
"prompt",
|
|
423
|
+
"nodeId",
|
|
424
|
+
"emitMode",
|
|
425
|
+
"emitLabel",
|
|
426
|
+
] as const;
|
|
427
|
+
|
|
428
|
+
/** The shared late-binding/preflight scaffolding variables every node's subProcess occupies. An emit
|
|
429
|
+
* named after one collides with the node's own wiring regardless of kind, so it is reserved for ALL
|
|
430
|
+
* kinds. */
|
|
431
|
+
export const DELIVERY_SCAFFOLDING_VARS = ["boundFacts", "nodeInputs", "nodeInputsPresent"] as const;
|
|
432
|
+
|
|
433
|
+
/** The RESERVED variable names an emitted-fact name must not collide with, **kind-aware** (issue #863
|
|
434
|
+
* review — threads r4181027093 / r4181027147 / r4181322008). A reserved name is one a colliding emit
|
|
435
|
+
* would genuinely break, and what breaks depends on the node kind:
|
|
436
|
+
*
|
|
437
|
+
* • **Escalation controls** ({@link ESCALATION_LOCAL_VARS} — `decision`/`value`/`escalationNote`) and the shared
|
|
438
|
+
* **scaffolding** ({@link DELIVERY_SCAFFOLDING_VARS}) are reserved for EVERY kind: they occupy the
|
|
439
|
+
* node's subProcess scope and an escalation Continue / the late-binding wiring writes them no matter
|
|
440
|
+
* how the emit's source is mapped.
|
|
441
|
+
*
|
|
442
|
+
* • **Config variables** are reserved ONLY for the kind whose own config they are — and only for
|
|
443
|
+
* `agent`/`connector`, the kinds whose emit source IS the fact's own name ({@link factSourceVar}).
|
|
444
|
+
* The retry reset clears every declared emit source var, so an `agent` emitting `appendPrompt`/
|
|
445
|
+
* `jobType`/`nodeTimeout`/`transcriptUrlBase`, or a `connector` emitting `target`/`payload`/
|
|
446
|
+
* `dedupeKey`/`nodeTimeout`, has its CONFIG nulled without re-entering the input mappings — the
|
|
447
|
+
* retried node activates unconfigured. An `agent` emitting `target` (a connector-only config var) is
|
|
448
|
+
* NOT a collision: an agent seeds no `target`. A cross-kind name is therefore allowed.
|
|
449
|
+
*
|
|
450
|
+
* • A `wait`/`human` emit's source is a FIXED intermediate (`detail`/`mergedSha`/`prCount`/
|
|
451
|
+
* `resolvedArtifact`, `humanEmitValue`/`humanEmitArtifact`), never the fact's own name, so its config
|
|
452
|
+
* vars cannot collide with an emit source — a `wait` emitting `target` or a `human` emitting `prompt`
|
|
453
|
+
* is fine. (`decision`/`value`/`escalationNote` stay reserved for these kinds via the escalation controls.)
|
|
454
|
+
*
|
|
455
|
+
* The node-local RESULT sets ({@link AGENT_RESULT_LOCAL_VARS} / {@link CONNECTOR_RESULT_LOCAL_VARS})
|
|
456
|
+
* are deliberately NOT reserved — with ONE exception. An agent emitting `pr` (the canonical converge
|
|
457
|
+
* shape) writes the same node-local value the result field holds, so they agree, and the retry reset
|
|
458
|
+
* correctly clears both. Reserving the whole set would forbid the flagship
|
|
459
|
+
* `agent → connector[converge] → wait[pr]` pattern. The exception is the agent completion-control
|
|
460
|
+
* `status`: the producer status gate reads that SAME node-local field as the node's completion status
|
|
461
|
+
* and accepts only `done`/`opened`/`skipped`, so an agent emit named `status` whose routing value is
|
|
462
|
+
* anything else (`approved`, …) ALWAYS escalates, and `status="done"` can never take an `approved`
|
|
463
|
+
* branch — the emit contract and the gate conflict. `status` is therefore reserved for an `agent`
|
|
464
|
+
* (a connector has no producer status gate, so its `status` emit collides with nothing and stays
|
|
465
|
+
* allowed); the rest of the result set (`pr`, `summary`, …) remains unreserved.
|
|
466
|
+
* {@link validateDeliveryGraph} rejects a reserved name (fail-closed at authoring time); derived from
|
|
467
|
+
* the same source-of-truth sets so it can never drift from them. */
|
|
468
|
+
export function reservedDeliveryFactNames(kind: DeliveryNodeKind): readonly string[] {
|
|
469
|
+
const config =
|
|
470
|
+
kind === "agent" ? AGENT_CONFIG_VARS : kind === "connector" ? CONNECTOR_CONFIG_VARS : [];
|
|
471
|
+
// `status` is reserved ONLY for an agent (the producer-gate completion control — see the doc above).
|
|
472
|
+
const completionControl = kind === "agent" ? ["status"] : [];
|
|
473
|
+
return [...new Set<string>([...ESCALATION_LOCAL_VARS, ...DELIVERY_SCAFFOLDING_VARS, ...config, ...completionControl])];
|
|
474
|
+
}
|
|
475
|
+
|
|
262
476
|
/** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
|
|
263
477
|
* within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
|
|
264
478
|
* compile/render steps trust these ids: an id with whitespace, a leading digit, or an over-long value
|
|
@@ -2090,6 +2304,71 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
2090
2304
|
message: "`human` config, when present, must be an object",
|
|
2091
2305
|
code: "missing-config",
|
|
2092
2306
|
});
|
|
2307
|
+
} else if (
|
|
2308
|
+
isRecord(rawNode.human) &&
|
|
2309
|
+
rawNode.human.formKey !== undefined &&
|
|
2310
|
+
typeof rawNode.human.formKey !== "string"
|
|
2311
|
+
) {
|
|
2312
|
+
// A PRESENT-but-non-string `human.formKey` (`42`, `{}`, `[]`, `true`) is the fail-open sibling of
|
|
2313
|
+
// the string-gated checks below: text/JSON ingress can pass a non-string, the `typeof === "string"`
|
|
2314
|
+
// branch is skipped, the graph is ACCEPTED, and the compiler silently falls back to a generated form
|
|
2315
|
+
// instead of the author's declared executable `<zeebe:formDefinition formId=…>` key. Reject it
|
|
2316
|
+
// explicitly — fail CLOSED on a malformed executable config rather than silently dropping it (PR #863
|
|
2317
|
+
// review). The required-config loop above already rejects a non-string `agent.jobType`/`connector.target`
|
|
2318
|
+
// (both REQUIRED, so `typeof value !== "string"` is caught there); `human` config is optional, leaving
|
|
2319
|
+
// `formKey` the one baked-verbatim field that reaches here untyped.
|
|
2320
|
+
errors.push({
|
|
2321
|
+
path: `${path}.human.formKey`,
|
|
2322
|
+
message: "`human.formKey`, when present, must be a string",
|
|
2323
|
+
code: "invalid-form-key",
|
|
2324
|
+
});
|
|
2325
|
+
} else if (isRecord(rawNode.human) && typeof rawNode.human.formKey === "string") {
|
|
2326
|
+
// A delivery `human` node's explicit `human.formKey` is baked VERBATIM into the executable
|
|
2327
|
+
// `<zeebe:formDefinition formId=…>` attribute (it resolves a deployed `.form` by value), so — exactly
|
|
2328
|
+
// like `agent.jobType` above — it must be REJECTED, not silently rewritten/redacted, when it carries a
|
|
2329
|
+
// character the attribute sanitiser would strip/fold or a credential-bearing URL that would leak into
|
|
2330
|
+
// the compiled BPMN. The compiler renders it through `attr()` (so a quote can no longer break out of
|
|
2331
|
+
// the attribute), but that neither undoes a silent XML-rewrite nor keeps a credential URL out of the
|
|
2332
|
+
// deployed model — this authoring-time gate does (PR #863 review — HIGH formKey finding at
|
|
2333
|
+
// deliveryGraphCompiler.ts:2361). The runtime TRIMS the key before use (`resolveHumanForm`), so
|
|
2334
|
+
// validate the trimmed value and skip blank (blank → generic form, carries nothing to leak).
|
|
2335
|
+
const formKey = rawNode.human.formKey.trim();
|
|
2336
|
+
if (formKey.length > 0) {
|
|
2337
|
+
if (hasXmlInvalidChars(formKey) || hasAttrNormalizedWhitespace(formKey)) {
|
|
2338
|
+
errors.push({
|
|
2339
|
+
path: `${path}.human.formKey`,
|
|
2340
|
+
message:
|
|
2341
|
+
`\`human.formKey\` ${JSON.stringify(redactString(stripXmlInvalidChars(formKey)))} contains a character that would be ` +
|
|
2342
|
+
"silently rewritten when emitted as the executable `<zeebe:formDefinition formId=…>` attribute — an XML-1.0-invalid " +
|
|
2343
|
+
"character (control characters, U+FFFE/U+FFFF, or an unpaired surrogate) that the sanitiser strips, or attribute " +
|
|
2344
|
+
"whitespace (tab, LF, CR) that XML attribute-value normalization folds to a space — so it would resolve a DIFFERENT " +
|
|
2345
|
+
"(wrong) deployed form and must be rejected rather than silently rewritten into a different form key",
|
|
2346
|
+
code: "invalid-form-key",
|
|
2347
|
+
});
|
|
2348
|
+
} else if (
|
|
2349
|
+
isUrlShaped(formKey) ||
|
|
2350
|
+
hasEmbeddedCredential(formKey) ||
|
|
2351
|
+
hasEmbeddedUrl(formKey) ||
|
|
2352
|
+
hasSchemeRelativeAuthority(formKey)
|
|
2353
|
+
) {
|
|
2354
|
+
// One code (not jobType's five per-shape codes) covers every URL/credential shape: a form key
|
|
2355
|
+
// names a deployed `.form`, so the distinction between a whole-value URL, an embedded
|
|
2356
|
+
// `//user:pass@host`, an explicit-scheme `scheme://…`, and a scheme-relative `//host` is
|
|
2357
|
+
// immaterial — none is a legal form id, and the operator fix is the same. Gated behind the
|
|
2358
|
+
// XML/whitespace check above so a single value is reported once. Message redacted so the 400
|
|
2359
|
+
// never echoes a credential.
|
|
2360
|
+
errors.push({
|
|
2361
|
+
path: `${path}.human.formKey`,
|
|
2362
|
+
message:
|
|
2363
|
+
`\`human.formKey\` ${JSON.stringify(redactString(stripXmlInvalidChars(formKey)))} is URL-/credential-shaped — a form ` +
|
|
2364
|
+
"key names a deployed `.form` resource, never a URL, and since the executable `<zeebe:formDefinition formId=…>` " +
|
|
2365
|
+
"carries it verbatim, a credential embedded in it (`//user:pass@host`, `scheme://…?token=…`, or a scheme-relative " +
|
|
2366
|
+
"`//host?token=…`) would leak into the compiled BPMN even though the preview path redacts it. Use a plain deployed " +
|
|
2367
|
+
"form id and carry any endpoint/credential elsewhere",
|
|
2368
|
+
code: "credential-in-form-key",
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2093
2372
|
}
|
|
2094
2373
|
|
|
2095
2374
|
// Collect + validate this node's typed emitted facts (uniqueness within the node). Registered
|
|
@@ -2142,6 +2421,32 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
2142
2421
|
});
|
|
2143
2422
|
return;
|
|
2144
2423
|
}
|
|
2424
|
+
if (isDeliveryNodeKind(kind) && reservedDeliveryFactNames(kind).includes(rawFact.name)) {
|
|
2425
|
+
// Fail CLOSED on a reserved-name collision (issue #863 review — threads r4181027093 /
|
|
2426
|
+
// r4181027147 / r4181322008). A fact name is an otherwise-unrestricted identifier, but an
|
|
2427
|
+
// `agent`/`connector` node's emit source IS the fact's own name, declared node-local on the
|
|
2428
|
+
// subProcess — the SAME scope the escalation form controls (`decision`/`value`/`escalationNote`) and
|
|
2429
|
+
// the node's OWN seeded config variables occupy. An emit named `decision` is overwritten by
|
|
2430
|
+
// an escalation Continue (publishing `<el>_decision="continue"` instead of the agent's
|
|
2431
|
+
// routing value); an emit named after the node's OWN config (`target`/`payload`/`dedupeKey`/
|
|
2432
|
+
// `nodeTimeout` on a connector, `appendPrompt`/`jobType`/… on an agent) is NULLED by the
|
|
2433
|
+
// retry reset, so the retried node activates unconfigured. The set is KIND-AWARE
|
|
2434
|
+
// (`reservedDeliveryFactNames`): only the kinds whose emit source is the fact's own name
|
|
2435
|
+
// reserve their own config, so a `wait` emitting `target` or a `human` emitting `prompt`
|
|
2436
|
+
// (whose sources are fixed intermediates) is NOT rejected. Reject the whole CLASS at
|
|
2437
|
+
// authoring time rather than patch one name.
|
|
2438
|
+
errors.push({
|
|
2439
|
+
path: `${path}.emits[${j}].name`,
|
|
2440
|
+
message:
|
|
2441
|
+
`emitted fact name "${rawFact.name}" collides with a reserved delivery variable ` +
|
|
2442
|
+
`(an escalation control, the shared scaffolding, or a ${kind} node's own subProcess ` +
|
|
2443
|
+
"config variable) — an escalation Continue would overwrite it or a Retry would null " +
|
|
2444
|
+
"the node's config. " +
|
|
2445
|
+
"Rename the fact (e.g. `decision` → `verdict`, `target` → `targetRef`).",
|
|
2446
|
+
code: "invalid-fact-name",
|
|
2447
|
+
});
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2145
2450
|
if (!isDeliveryFactType(rawFact.type)) {
|
|
2146
2451
|
// emits are TYPED (Decision 3/4). An invalid/missing `type` must be rejected even when the
|
|
2147
2452
|
// OpenAPI shape validator is bypassed, or a later step reading the type allowlist breaks.
|
|
@@ -2163,6 +2468,34 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
2163
2468
|
nodeFacts.set(id, facts);
|
|
2164
2469
|
nodeFactTypes.set(id, factTypes);
|
|
2165
2470
|
}
|
|
2471
|
+
|
|
2472
|
+
// Reject a `human` node whose emits no static form can capture (PR #863 review — threads
|
|
2473
|
+
// deliveryGraphCompiler.ts:1859 / :2451 / :1927). A human form captures ONE value: the generic form a
|
|
2474
|
+
// single non-artifact `value`, the publish form a single `resolvedArtifact`. A node emitting ≥2 typed
|
|
2475
|
+
// facts resolves to the agent-router (no static form holds them) — but the compiled generic form has
|
|
2476
|
+
// no control for the extra facts, and a human node has no Retry path, so completing the form would
|
|
2477
|
+
// publish NULL for every uncapturable emit and permanently schedule downstream consumers with missing
|
|
2478
|
+
// required facts. An explicit `human.formKey` is NOT a workaround for a ≥2-emit node (thread
|
|
2479
|
+
// r4184443511): for a SINGLE emit the compiled task's ioMapping (`humanBodyLines`) now reads the
|
|
2480
|
+
// fact-named field ahead of the canonical `value`/`resolvedArtifact` control (so a bespoke
|
|
2481
|
+
// single-value/single-artifact form IS wired — thread deliveryGraphCompiler.ts:1929), but that
|
|
2482
|
+
// per-fact mapping is emitted for the single-emit case only; a ≥2-emit explicit-form node still reads
|
|
2483
|
+
// just the fixed `value`/`resolvedArtifact`/`note` controls and would publish null for every extra
|
|
2484
|
+
// emit. Reject the whole CLASS at authoring time: split the emits across single-emit human nodes.
|
|
2485
|
+
if (kind === "human" && factTypes.size >= 2) {
|
|
2486
|
+
errors.push({
|
|
2487
|
+
path: `${path}.emits`,
|
|
2488
|
+
message:
|
|
2489
|
+
`human node "${String(id)}" emits ${factTypes.size} typed facts but no static form can ` +
|
|
2490
|
+
"capture more than one value (the generic form captures a single non-artifact `value`, the " +
|
|
2491
|
+
"publish form a single `resolvedArtifact`) — completing its form would publish null for the " +
|
|
2492
|
+
"uncapturable emits, and a human node has no Retry path to recover. An explicit `human.formKey` " +
|
|
2493
|
+
"does not help: the compiled task's ioMapping reads only the fixed value/resolvedArtifact/note " +
|
|
2494
|
+
"controls, never a bespoke form's per-fact fields. Split the emits across separate single-emit " +
|
|
2495
|
+
"human nodes.",
|
|
2496
|
+
code: "human-unroutable-emits",
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2166
2499
|
});
|
|
2167
2500
|
|
|
2168
2501
|
// Pass 2: edges. Resolve each endpoint against the node set and each qualified `from` against the
|