@nanobpm/nano-workforce 0.201.0 → 0.201.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +153 -0
- package/app/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- 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/mergeLoopBehaviour.test.ts +46 -1
- package/app/redactText.ts +88 -0
- package/app/service.ts +110 -2
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- 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/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
|
@@ -480,6 +480,178 @@ test("invalid-fact-name: an emitted fact name over the openapi 128-char cap is r
|
|
|
480
480
|
assertEquals(err.path, "nodes[0].emits[0].name");
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
test("invalid-fact-name: an emit named after an escalation control (`decision`/`value`/`escalationNote`) is rejected — a Continue would overwrite the fact (r4181027093)", () => {
|
|
484
|
+
// Regression guard (PR #863 Copilot High, thread r4181027093): an agent/connector emit's source is the
|
|
485
|
+
// fact's own name, declared node-local in the SAME scope as the escalation form's `decision`/`value`/
|
|
486
|
+
// `escalationNote`. An agent emitting `decision` that reaches a contract escalation has its fact overwritten by
|
|
487
|
+
// the form completion (`decision="continue"`), so the subProcess publishes `<el>_decision="continue"`
|
|
488
|
+
// instead of the agent's routing value. The validator must reject the whole class fail-closed.
|
|
489
|
+
// (`note` is NOT an escalation control — it is a plan.md worker RESULT field; the retry-guidance
|
|
490
|
+
// control is the worker-unwritable `escalationNote`, PR #863 "Previously missed" deliveryGraphCompiler.ts:2156.)
|
|
491
|
+
for (const name of ["decision", "value", "escalationNote"]) {
|
|
492
|
+
const errors = validateDeliveryGraph({
|
|
493
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name, type: "string" }] }],
|
|
494
|
+
});
|
|
495
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
496
|
+
assertEquals(err.path, "nodes[0].emits[0].name", `emit '${name}' is rejected`);
|
|
497
|
+
assert(err.message.includes("reserved"), `emit '${name}' names the reserved collision, got: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("invalid-fact-name: an emit named after a connector config variable (`target`/`payload`/`dedupeKey`/`nodeTimeout`) is rejected — a Retry would null the config (r4181027147)", () => {
|
|
502
|
+
// Regression guard (PR #863 Copilot High, thread r4181027147): the retry reset clears every declared
|
|
503
|
+
// emit source var; a connector emitting `target`/`payload`/`dedupeKey`/`nodeTimeout` (also subProcess
|
|
504
|
+
// config vars) has them NULLED without re-entering the input mappings, so the retried connector
|
|
505
|
+
// activates unconfigured. Reject the collision at authoring time.
|
|
506
|
+
for (const name of ["target", "payload", "dedupeKey", "nodeTimeout"]) {
|
|
507
|
+
const errors = validateDeliveryGraph({
|
|
508
|
+
nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name, type: "string" }] }],
|
|
509
|
+
});
|
|
510
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
511
|
+
assertEquals(err.path, "nodes[0].emits[0].name", `connector emit '${name}' is rejected`);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("invalid-fact-name: an emit named after shared late-binding/preflight scaffolding is rejected (boundFacts/nodeInputs/nodeInputsPresent)", () => {
|
|
516
|
+
// The reserved set also covers the shared subProcess scaffolding that lives in the emit's scope.
|
|
517
|
+
for (const name of ["boundFacts", "nodeInputs", "nodeInputsPresent"]) {
|
|
518
|
+
const errors = validateDeliveryGraph({
|
|
519
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name, type: "string" }] }],
|
|
520
|
+
});
|
|
521
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
522
|
+
assertEquals(err.path, "nodes[0].emits[0].name", `emit '${name}' is rejected`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("a node-local RESULT field is NOT a reserved emit name — an agent may emit `pr` (the canonical converge shape)", () => {
|
|
527
|
+
// Guard against over-reserving (PR #863 design): the result sets (AGENT_RESULT_LOCAL_VARS /
|
|
528
|
+
// CONNECTOR_RESULT_LOCAL_VARS) are NOT reserved — an agent emitting `pr` writes the same node-local
|
|
529
|
+
// value the result field holds, and the retry reset correctly clears both. Reserving them would
|
|
530
|
+
// forbid the flagship `agent → connector[converge] → wait[pr]` pattern.
|
|
531
|
+
const errors = validateDeliveryGraph({
|
|
532
|
+
nodes: [{ id: "open", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "pr", type: "pr" }] }],
|
|
533
|
+
});
|
|
534
|
+
assertEquals(errors.length, 0, `an agent emitting 'pr' validates, got: ${JSON.stringify(errors)}`);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("invalid-fact-name: an AGENT emit named `status` is reserved — the producer gate reads that node-local field as its completion status", () => {
|
|
538
|
+
// Regression guard (PR #863 Copilot Medium "Previously missed", deliveryGraph.ts:418): the result-set
|
|
539
|
+
// exemption was too broad for an agent emit named `status`. The producer status gate
|
|
540
|
+
// (`agentContractProceedCondition`) interprets that same node-local field as the completion status and
|
|
541
|
+
// accepts only `done`/`opened`/`skipped`, while the emit contract may require a different routing
|
|
542
|
+
// value — `status="approved"` always escalates, and `status="done"` can never take an `approved`
|
|
543
|
+
// branch. Unlike an aligned result fact such as `pr` (whose emit value and result field agree),
|
|
544
|
+
// `status` is CONSTRAINED by the gate, so the two uses conflict. Reserve the agent completion-control
|
|
545
|
+
// `status` while continuing to allow aligned result facts such as `pr`.
|
|
546
|
+
const errors = validateDeliveryGraph({
|
|
547
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "status", type: "string" }] }],
|
|
548
|
+
});
|
|
549
|
+
const err = hasCode(errors, "invalid-fact-name");
|
|
550
|
+
assertEquals(err.path, "nodes[0].emits[0].name", "an agent emitting 'status' is rejected");
|
|
551
|
+
assert(err.message.includes("reserved"), `emit 'status' names the reserved collision, got: ${err.message}`);
|
|
552
|
+
// A CONNECTOR has no producer status gate, so its `status` emit collides with nothing — allowed.
|
|
553
|
+
const connector = validateDeliveryGraph({
|
|
554
|
+
nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "status", type: "string" }] }],
|
|
555
|
+
});
|
|
556
|
+
assertEquals(connector.length, 0, `a connector emitting 'status' validates (no producer gate), got: ${JSON.stringify(connector)}`);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("invalid-fact-name is KIND-AWARE: an agent may emit a connector-only config name (`target`), and vice versa (r4181322008)", () => {
|
|
560
|
+
// Regression guard (PR #863 Copilot Medium, thread r4181322008): the reserved set reserves only the
|
|
561
|
+
// node's OWN kind's config. An agent seeds no `target`/`payload`/`dedupeKey` (connector-only), so an
|
|
562
|
+
// agent emitting `target` collides with nothing; symmetrically a connector seeds no `jobType`/
|
|
563
|
+
// `appendPrompt` (agent-only). Only the shared `nodeTimeout` is reserved for both.
|
|
564
|
+
const agentTarget = validateDeliveryGraph({
|
|
565
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "target", type: "string" }] }],
|
|
566
|
+
});
|
|
567
|
+
assertEquals(agentTarget.length, 0, `an agent emitting 'target' validates, got: ${JSON.stringify(agentTarget)}`);
|
|
568
|
+
const connectorJobType = validateDeliveryGraph({
|
|
569
|
+
nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "jobType", type: "string" }] }],
|
|
570
|
+
});
|
|
571
|
+
assertEquals(connectorJobType.length, 0, `a connector emitting 'jobType' validates, got: ${JSON.stringify(connectorJobType)}`);
|
|
572
|
+
// …but each kind's OWN config is still reserved (the collision is real there).
|
|
573
|
+
const agentAppend = validateDeliveryGraph({
|
|
574
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "appendPrompt", type: "string" }] }],
|
|
575
|
+
});
|
|
576
|
+
assertEquals(hasCode(agentAppend, "invalid-fact-name").path, "nodes[0].emits[0].name", "an agent emitting its OWN config 'appendPrompt' is rejected");
|
|
577
|
+
const connectorTarget = validateDeliveryGraph({
|
|
578
|
+
nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "target", type: "string" }] }],
|
|
579
|
+
});
|
|
580
|
+
assertEquals(hasCode(connectorTarget, "invalid-fact-name").path, "nodes[0].emits[0].name", "a connector emitting its OWN config 'target' is rejected");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("invalid-fact-name does NOT apply a config restriction to wait/human emits, whose source is a fixed intermediate (r4181322008)", () => {
|
|
584
|
+
// A wait/human emit's source is NOT the fact's own name (factSourceVar maps it to `detail`/
|
|
585
|
+
// `humanEmitValue`/…), so its config vars can't collide with an emit source — a wait emitting
|
|
586
|
+
// `target`/`probe` or a human emitting `prompt`/`emitMode` is fine. Only the escalation
|
|
587
|
+
// controls + shared scaffolding stay reserved for these kinds.
|
|
588
|
+
const waitTarget = validateDeliveryGraph({
|
|
589
|
+
nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "o/r@main" }, emits: [{ name: "target", type: "string" }, { name: "probe", type: "string" }] }],
|
|
590
|
+
});
|
|
591
|
+
assertEquals(waitTarget.length, 0, `a wait emitting 'target'/'probe' validates, got: ${JSON.stringify(waitTarget)}`);
|
|
592
|
+
// `prompt`/`emitMode` are not reserved for a human node. Assert each as its OWN single-emit human node
|
|
593
|
+
// — a two-emit human node with no form is separately rejected (`human-unroutable-emits`, thread
|
|
594
|
+
// deliveryGraphCompiler.ts:1859), which would otherwise mask the reserved-name intent under test here.
|
|
595
|
+
const humanPrompt = validateDeliveryGraph({
|
|
596
|
+
nodes: [
|
|
597
|
+
{ id: "h1", kind: "human", human: { prompt: "do it" }, emits: [{ name: "prompt", type: "string" }] },
|
|
598
|
+
{ id: "h2", kind: "human", human: { prompt: "do it" }, emits: [{ name: "emitMode", type: "string" }] },
|
|
599
|
+
],
|
|
600
|
+
});
|
|
601
|
+
assertEquals(humanPrompt.length, 0, `a human emitting 'prompt'/'emitMode' validates, got: ${JSON.stringify(humanPrompt)}`);
|
|
602
|
+
// …but the escalation controls + scaffolding are STILL reserved for wait/human.
|
|
603
|
+
const humanDecision = validateDeliveryGraph({
|
|
604
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "decision", type: "string" }] }],
|
|
605
|
+
});
|
|
606
|
+
assertEquals(hasCode(humanDecision, "invalid-fact-name").path, "nodes[0].emits[0].name", "a human emitting 'decision' (escalation control) is still rejected");
|
|
607
|
+
const waitScaffold = validateDeliveryGraph({
|
|
608
|
+
nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "o/r@main" }, emits: [{ name: "nodeInputs", type: "string" }] }],
|
|
609
|
+
});
|
|
610
|
+
assertEquals(hasCode(waitScaffold, "invalid-fact-name").path, "nodes[0].emits[0].name", "a wait emitting 'nodeInputs' (scaffolding) is still rejected");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("human-unroutable-emits: a human node emitting ≥2 facts with no form is rejected — no static form captures them (thread deliveryGraphCompiler.ts:1859)", () => {
|
|
614
|
+
// Class guard (PR #863 Copilot High): a human form captures ONE value and a human node has no Retry
|
|
615
|
+
// path, so a node emitting two or more facts with no explicit `human.formKey` resolves to the
|
|
616
|
+
// agent-router — no static form holds them — and completing its form would publish null for the
|
|
617
|
+
// uncapturable emits. Reject the class at authoring time rather than silently starve consumers.
|
|
618
|
+
const twoScalars = validateDeliveryGraph({
|
|
619
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] }],
|
|
620
|
+
});
|
|
621
|
+
assertEquals(hasCode(twoScalars, "human-unroutable-emits").path, "nodes[0].emits", "two non-artifact emits with no form are rejected");
|
|
622
|
+
// The carve-out the finding cites: one scalar + one artifact — the generic form has no resolvedArtifact
|
|
623
|
+
// control and the publish form no value control, so NO static form captures both. Also rejected.
|
|
624
|
+
const scalarPlusArtifact = validateDeliveryGraph({
|
|
625
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }, { name: "report", type: "artifact" }] }],
|
|
626
|
+
});
|
|
627
|
+
assertEquals(hasCode(scalarPlusArtifact, "human-unroutable-emits").path, "nodes[0].emits", "one scalar + one artifact with no form is rejected");
|
|
628
|
+
// An explicit `human.formKey` is NOT a workaround: the compiled human task's ioMapping reads only the
|
|
629
|
+
// fixed `value`/`resolvedArtifact`/`note` controls (humanBodyLines), never a bespoke form's per-fact
|
|
630
|
+
// fields, so a ≥2-emit explicit-form node would STILL publish null for every non-artifact emit.
|
|
631
|
+
// Reject it too (PR #863 Copilot High, thread r4184443511) — the only correct shape is single-emit
|
|
632
|
+
// human nodes.
|
|
633
|
+
const explicitForm = validateDeliveryGraph({
|
|
634
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it", formKey: "bespoke-multi" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] }],
|
|
635
|
+
});
|
|
636
|
+
assertEquals(hasCode(explicitForm, "human-unroutable-emits").path, "nodes[0].emits", "an explicit human.formKey does NOT make a ≥2-emit node capturable — the ioMapping cannot read its bespoke fields");
|
|
637
|
+
// Single-emit human nodes are capturable → NOT rejected (the guard must not over-reject).
|
|
638
|
+
const singleScalar = validateDeliveryGraph({
|
|
639
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }] }],
|
|
640
|
+
});
|
|
641
|
+
assertEquals(singleScalar.filter((e) => e.code === "human-unroutable-emits").length, 0, "a single non-artifact emit is capturable (generic form)");
|
|
642
|
+
const singleArtifact = validateDeliveryGraph({
|
|
643
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "art", type: "artifact" }] }],
|
|
644
|
+
});
|
|
645
|
+
assertEquals(singleArtifact.filter((e) => e.code === "human-unroutable-emits").length, 0, "a single artifact emit is capturable (publish form)");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("a non-reserved emit name still validates (the reserved guard does not over-reject)", () => {
|
|
649
|
+
const errors = validateDeliveryGraph({
|
|
650
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "verdict", type: "string" }, { name: "targetRef", type: "string" }] }],
|
|
651
|
+
});
|
|
652
|
+
assertEquals(errors.length, 0, `a non-reserved emit name passes, got: ${JSON.stringify(errors)}`);
|
|
653
|
+
});
|
|
654
|
+
|
|
483
655
|
test("fact-name `resumeValid` is NOT reserved — a user emit may use it (PR #876 review: the resume-validation gate's flag is an internal compiler-generated var, not a user fact name, so reserving `resumeValid` would needlessly break recompilation of durable rows that already carry a fact of that name)", () => {
|
|
484
656
|
const errors = validateDeliveryGraph({
|
|
485
657
|
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "resumeValid", type: "boolean" }] }],
|
|
@@ -722,6 +894,85 @@ test("invalid-job-type: an `agent.jobType` carrying attribute whitespace (LF) is
|
|
|
722
894
|
hasCode(errors, "invalid-job-type");
|
|
723
895
|
});
|
|
724
896
|
|
|
897
|
+
test("invalid-form-key: a `human.formKey` carrying an XML-1.0-invalid character is rejected, not silently rewritten into a different executable form id (PR #863 review)", () => {
|
|
898
|
+
// `human.formKey` is emitted VERBATIM as the executable `<zeebe:formDefinition formId=…>` (it resolves
|
|
899
|
+
// a deployed `.form` by value). An XML-1.0-forbidden control char would be STRIPPED by the attribute
|
|
900
|
+
// sanitiser — resolving a DIFFERENT (wrong) form — so it must be rejected, exactly like `agent.jobType`.
|
|
901
|
+
const errors = validateDeliveryGraph({
|
|
902
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "my-form\u0001x" } }],
|
|
903
|
+
edges: [],
|
|
904
|
+
});
|
|
905
|
+
hasCode(errors, "invalid-form-key");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("invalid-form-key: a `human.formKey` carrying attribute whitespace (LF) is rejected — XML attribute-value normalization would fold it to a space, resolving a different form (PR #863 review)", () => {
|
|
909
|
+
const errors = validateDeliveryGraph({
|
|
910
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "my\nform" } }],
|
|
911
|
+
edges: [],
|
|
912
|
+
});
|
|
913
|
+
hasCode(errors, "invalid-form-key");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("invalid-form-key: a PRESENT-but-non-string `human.formKey` is rejected, not silently dropped to a generated form (PR #863 review — fail-open class)", () => {
|
|
917
|
+
// The class: a non-string optional config value bypasses the `typeof === "string"`-gated validators
|
|
918
|
+
// and is silently accepted, so the compiler falls back to a generated form instead of the author's
|
|
919
|
+
// declared executable `<zeebe:formDefinition formId=…>` key. Every non-string shape must fail closed.
|
|
920
|
+
for (const bad of [42, {}, [], true]) {
|
|
921
|
+
const errors = validateDeliveryGraph({
|
|
922
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: bad } }],
|
|
923
|
+
edges: [],
|
|
924
|
+
});
|
|
925
|
+
hasCode(errors, "invalid-form-key");
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("credential-in-form-key: a URL-/credential-shaped `human.formKey` is REJECTED — it is baked verbatim into the executable `<zeebe:formDefinition formId=…>`, so an embedded credential would leak into the compiled BPMN the preview door returns (PR #863 review)", () => {
|
|
930
|
+
// whole-value URL carrying a `?token=` query (no userinfo needed to be a leak risk)
|
|
931
|
+
const whole = validateDeliveryGraph({
|
|
932
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "https://evil.example/f.form?token=secret" } }],
|
|
933
|
+
edges: [],
|
|
934
|
+
});
|
|
935
|
+
const err = hasCode(whole, "credential-in-form-key");
|
|
936
|
+
assert(!err.message.includes("token=secret"), `the credential-in-form-key message must redact the query token, got: ${err.message}`);
|
|
937
|
+
// embedded `//user:pass@host` past an otherwise plausible token
|
|
938
|
+
hasCode(
|
|
939
|
+
validateDeliveryGraph({
|
|
940
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "my-form //user:pass@evil.example/f" } }],
|
|
941
|
+
edges: [],
|
|
942
|
+
}),
|
|
943
|
+
"credential-in-form-key",
|
|
944
|
+
);
|
|
945
|
+
// explicit-scheme embedded URL with a `?token=` query
|
|
946
|
+
hasCode(
|
|
947
|
+
validateDeliveryGraph({
|
|
948
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "my-form https://evil.example/f?token=secret" } }],
|
|
949
|
+
edges: [],
|
|
950
|
+
}),
|
|
951
|
+
"credential-in-form-key",
|
|
952
|
+
);
|
|
953
|
+
// scheme-relative `//host?token=` authority
|
|
954
|
+
hasCode(
|
|
955
|
+
validateDeliveryGraph({
|
|
956
|
+
nodes: [{ id: "step", kind: "human", human: { formKey: "my-form //evil.example/f?token=secret" } }],
|
|
957
|
+
edges: [],
|
|
958
|
+
}),
|
|
959
|
+
"credential-in-form-key",
|
|
960
|
+
);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("human.formKey: a clean form id (and a blank/absent one) passes the formKey gates", () => {
|
|
964
|
+
for (const formKey of ["my-custom-form", " ", undefined]) {
|
|
965
|
+
assertEquals(
|
|
966
|
+
validateDeliveryGraph({
|
|
967
|
+
nodes: [{ id: "step", kind: "human", human: formKey === undefined ? {} : { formKey } }],
|
|
968
|
+
edges: [],
|
|
969
|
+
}).filter((e) => e.code === "invalid-form-key" || e.code === "credential-in-form-key"),
|
|
970
|
+
[],
|
|
971
|
+
`formKey ${JSON.stringify(formKey)} must pass the formKey gates`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
725
976
|
test("invalid-job-type: a clean `agent.jobType` (no XML-invalid characters, no attribute whitespace) passes validation", () => {
|
|
726
977
|
assertEquals(
|
|
727
978
|
validateDeliveryGraph({
|
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
|