@nanobpm/nano-workforce 0.141.0 → 0.142.0
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/app/convergeTargets.ts +30 -0
- package/app/deliveryConnector.ts +31 -20
- package/app/deliveryGraph.test.ts +111 -0
- package/app/deliveryGraph.ts +159 -5
- package/app/deliveryGraphCompiler.test.ts +51 -1
- package/app/deliveryGraphCompiler.ts +21 -3
- package/docs/adr/0005-agent-authored-delivery-graphs.md +5 -3
- package/docs/agent-guide.md +23 -10
- package/e2e/delivery-graph.e2e.ts +30 -0
- package/openapi.yaml +5 -2
- package/package.json +1 -1
- package/workers/delivery-connector/worker.test.ts +26 -8
- package/workers/delivery-connector/worker.ts +15 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.142.0](https://github.com/nanobpm/nano-workforce/compare/v0.141.0...v0.142.0) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** late-bind converge/wait PR from an emitted `pr` fact ([#548](https://github.com/nanobpm/nano-workforce/issues/548)) ([#550](https://github.com/nanobpm/nano-workforce/issues/550)) ([dbb3aab](https://github.com/nanobpm/nano-workforce/commit/dbb3aabfe8aeaa9951d2ce9f5a2b732eaa99748b))
|
|
6
|
+
|
|
1
7
|
## [0.141.0](https://github.com/nanobpm/nano-workforce/compare/v0.140.0...v0.141.0) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The converge-enrollment connector-target vocabulary (ADR 0005, issue #500) — the SINGLE, dependency-
|
|
2
|
+
// free source of truth for the `converge` / `converge-merge` literals and their derived semantics.
|
|
3
|
+
// Extracted from `deliveryConnector.ts` so the pure, import-free semantic validator (`deliveryGraph.ts`)
|
|
4
|
+
// can share the exact same predicate WITHOUT pulling in the connector module's urban/data-layer deps —
|
|
5
|
+
// a converge/wait node's late-binding validation (issue #548) must agree with the worker's dispatch
|
|
6
|
+
// branch on what "a converge target" is, and this module is what keeps them from drifting.
|
|
7
|
+
|
|
8
|
+
/** The review-only converge target: enrolls a PR into the shared convergence loop and STOPS at
|
|
9
|
+
* `converged` (never hands it to the merge loop). */
|
|
10
|
+
export const CONVERGE_TARGET = "converge";
|
|
11
|
+
|
|
12
|
+
/** The converge-AND-merge target: enrolls a PR into the shared convergence loop and drives the merge
|
|
13
|
+
* loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape). */
|
|
14
|
+
export const CONVERGE_MERGE_TARGET = "converge-merge";
|
|
15
|
+
|
|
16
|
+
/** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
|
|
17
|
+
* predicate the connector worker branches on to route a dispatch into `submitPr`, and the validator
|
|
18
|
+
* branches on to require a bound/literal PR (issue #548). */
|
|
19
|
+
export function isConvergeTarget(target: string): boolean {
|
|
20
|
+
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
24
|
+
* `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
|
|
25
|
+
* `convergeOnly` argument. An author may still override it per-dispatch via the connector payload's
|
|
26
|
+
* `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge target's `false`
|
|
27
|
+
* is unreachable. */
|
|
28
|
+
export function convergeOnlyForTarget(target: string): boolean {
|
|
29
|
+
return target === CONVERGE_TARGET;
|
|
30
|
+
}
|
package/app/deliveryConnector.ts
CHANGED
|
@@ -39,26 +39,14 @@ export const OUTCOME_DELIVERED = "delivered";
|
|
|
39
39
|
* convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
|
|
40
40
|
* target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
|
|
41
41
|
* `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
|
-
* defined to be.
|
|
43
|
-
*
|
|
44
|
-
export
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export function isConvergeTarget(target: string): boolean {
|
|
51
|
-
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
55
|
-
* `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
|
|
56
|
-
* `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
|
|
57
|
-
* override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
|
|
58
|
-
* `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
|
|
59
|
-
export function convergeOnlyForTarget(target: string): boolean {
|
|
60
|
-
return target === CONVERGE_TARGET;
|
|
61
|
-
}
|
|
42
|
+
* defined to be. The converge-target vocabulary lives in the dependency-free {@link ./convergeTargets.ts}
|
|
43
|
+
* so the pure validator can share it; re-exported here for the worker's existing import surface. */
|
|
44
|
+
export {
|
|
45
|
+
CONVERGE_MERGE_TARGET,
|
|
46
|
+
CONVERGE_TARGET,
|
|
47
|
+
convergeOnlyForTarget,
|
|
48
|
+
isConvergeTarget,
|
|
49
|
+
} from "./convergeTargets.ts";
|
|
62
50
|
|
|
63
51
|
/** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
|
|
64
52
|
export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
|
|
@@ -82,6 +70,29 @@ export interface BoundFact {
|
|
|
82
70
|
value: unknown;
|
|
83
71
|
}
|
|
84
72
|
|
|
73
|
+
/** Resolve a converge connector's effective target PR (issue #548), late-binding it from an upstream
|
|
74
|
+
* `agent` node's emitted `pr` fact so the canonical `agent → connector[converge-merge] → wait` shape
|
|
75
|
+
* needs NO hardcoded literal. Precedence, given the author-supplied `payload.pr` and the threaded
|
|
76
|
+
* `boundFacts`:
|
|
77
|
+
* • an explicit fact REFERENCE — a `payload.pr` string that exactly matches a threaded fact's
|
|
78
|
+
* `<from>.<name>` — resolves to that fact's value (an `owner/repo#N` PR ref emitted upstream);
|
|
79
|
+
* • an explicit LITERAL — any other `payload.pr` string — is returned as-is (a real `owner/repo#N`
|
|
80
|
+
* is never `<node>.<fact>`-shaped, so it can't collide with a reference), and `parsePr` validates it;
|
|
81
|
+
* • OMITTED (`payload.pr` absent) — binds the single incoming fact named `pr` (the canonical emit),
|
|
82
|
+
* else the single incoming bound fact when there is exactly one, else stays undefined so
|
|
83
|
+
* {@link readConvergeInput} raises its "requires payload.pr" error (fail closed).
|
|
84
|
+
* Pure + total (no PR parsing here — the caller validates), so it is unit-testable without the engine. */
|
|
85
|
+
export function resolveConvergePr(payloadPr: unknown, boundFacts: readonly BoundFact[]): unknown {
|
|
86
|
+
if (typeof payloadPr === "string" && payloadPr.trim() !== "") {
|
|
87
|
+
const ref = boundFacts.find((b) => `${b.from}.${b.name}` === payloadPr.trim());
|
|
88
|
+
return ref ? ref.value : payloadPr;
|
|
89
|
+
}
|
|
90
|
+
const named = boundFacts.filter((b) => b.name === "pr");
|
|
91
|
+
if (named.length === 1) return named[0].value;
|
|
92
|
+
if (named.length === 0 && boundFacts.length === 1) return boundFacts[0].value;
|
|
93
|
+
return payloadPr;
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
/** The effective dedupe key for a connector dispatch: the author-supplied `connector.dedupeKey` when
|
|
86
97
|
* present, else a graph-derived `<processInstanceKey>:<elementId>` — both STABLE across a re-activation
|
|
87
98
|
* of the same node instance (the engine re-delivers the same job with the same identity), so an
|
|
@@ -731,3 +731,114 @@ test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while c
|
|
|
731
731
|
assertEquals(validateDeliveryGraph(waitWith("continue")), []);
|
|
732
732
|
assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
|
|
733
733
|
});
|
|
734
|
+
|
|
735
|
+
// ── Pass 4 (#548): converge/wait PR late-binding ──────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
/** The canonical no-literal converge shape: `open` emits a `pr` fact, both the converge connector and
|
|
738
|
+
* the pr-wait reference it (`open.pr`) on incoming fact edges. */
|
|
739
|
+
function noLiteralConverge(overrides?: {
|
|
740
|
+
connectorPr?: unknown;
|
|
741
|
+
waitTarget?: string;
|
|
742
|
+
openEmitsPr?: boolean;
|
|
743
|
+
edges?: { from: string; to: string }[];
|
|
744
|
+
}) {
|
|
745
|
+
const emits = overrides?.openEmitsPr === false ? [{ name: "pr", type: "url" }] : [{ name: "pr", type: "pr" }];
|
|
746
|
+
const connector =
|
|
747
|
+
"connectorPr" in (overrides ?? {})
|
|
748
|
+
? { target: "converge-merge", payload: { pr: overrides?.connectorPr } }
|
|
749
|
+
: { target: "converge-merge", payload: { pr: "open.pr" } };
|
|
750
|
+
return {
|
|
751
|
+
name: "no-literal converge",
|
|
752
|
+
nodes: [
|
|
753
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" }, emits },
|
|
754
|
+
{ id: "land", kind: "connector", connector },
|
|
755
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: overrides?.waitTarget ?? "open.pr", match: { prState: "merged" } } },
|
|
756
|
+
],
|
|
757
|
+
edges: overrides?.edges ?? [
|
|
758
|
+
{ from: "open.pr", to: "land" },
|
|
759
|
+
{ from: "open.pr", to: "merged" },
|
|
760
|
+
],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
test("#548 a converge connector + pr-wait that reference a threaded `pr` fact validate", () => {
|
|
765
|
+
assertEquals(validateDeliveryGraph(noLiteralConverge()), []);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("#548 a LITERAL owner/repo#N PR on both the connector and the wait validates (no binding needed)", () => {
|
|
769
|
+
const g = noLiteralConverge({ connectorPr: "acme/repo#12", waitTarget: "acme/repo#12", edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
|
|
770
|
+
assertEquals(validateDeliveryGraph(g), []);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("#548 a PR reference that is NOT threaded by a fact edge is rejected (unbound-pr)", () => {
|
|
774
|
+
// The connector/wait name `open.pr`, but the edges carry only a plain completion dependency — the
|
|
775
|
+
// `pr` fact never flows in, so it can never late-bind.
|
|
776
|
+
const g = noLiteralConverge({ edges: [{ from: "open", to: "land" }, { from: "land", to: "merged" }] });
|
|
777
|
+
const errs = validateDeliveryGraph(g);
|
|
778
|
+
const unbound = errs.filter((e) => e.code === "unbound-pr");
|
|
779
|
+
assertEquals(unbound.length, 2, `both consumers are unbound: ${JSON.stringify(errs)}`);
|
|
780
|
+
assert(unbound.some((e) => e.path === "nodes[1].connector.payload.pr"));
|
|
781
|
+
assert(unbound.some((e) => e.path === "nodes[2].wait.target"));
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("#548 a PR reference to a non-`pr`-typed fact is rejected (unbound-pr)", () => {
|
|
785
|
+
const g = noLiteralConverge({ openEmitsPr: false }); // `open` emits `pr` as a `url`, not `pr`
|
|
786
|
+
const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
|
|
787
|
+
assert(err.message.includes("must reference a `pr`-typed fact") || err.message.includes('"url"'), err.message);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test("#548 a PR reference to an undeclared fact is rejected (unbound-pr)", () => {
|
|
791
|
+
// `open` emits no facts, but the connector references `open.pr`.
|
|
792
|
+
const g = {
|
|
793
|
+
name: "undeclared ref",
|
|
794
|
+
nodes: [
|
|
795
|
+
{ id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
|
|
796
|
+
{ id: "land", kind: "connector", connector: { target: "converge", payload: { pr: "open.pr" } } },
|
|
797
|
+
],
|
|
798
|
+
edges: [{ from: "open", to: "land" }],
|
|
799
|
+
};
|
|
800
|
+
const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
|
|
801
|
+
assert(err.message.includes("does not declare"), err.message);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("#548 a converge connector that OMITS payload.pr auto-binds a single threaded `pr` fact", () => {
|
|
805
|
+
const g = {
|
|
806
|
+
name: "omitted auto-bind",
|
|
807
|
+
nodes: [
|
|
808
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open" }, emits: [{ name: "pr", type: "pr" }] },
|
|
809
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge" } },
|
|
810
|
+
],
|
|
811
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
812
|
+
};
|
|
813
|
+
assertEquals(validateDeliveryGraph(g), []);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("#548 a converge connector with NO PR at all is rejected (unbound-pr)", () => {
|
|
817
|
+
const g = {
|
|
818
|
+
name: "no pr",
|
|
819
|
+
nodes: [
|
|
820
|
+
{ id: "open", kind: "agent", agent: { jobType: "j", prompt: "p" } },
|
|
821
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge" } },
|
|
822
|
+
],
|
|
823
|
+
edges: [{ from: "open", to: "land" }],
|
|
824
|
+
};
|
|
825
|
+
const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
|
|
826
|
+
assert(err.message.includes("has no target PR"), err.message);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("#548 a converge connector with MULTIPLE incoming `pr` facts is rejected as ambiguous (unbound-pr)", () => {
|
|
830
|
+
const g = {
|
|
831
|
+
name: "ambiguous pr",
|
|
832
|
+
nodes: [
|
|
833
|
+
{ id: "a", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
|
|
834
|
+
{ id: "b", kind: "agent", agent: { jobType: "j", prompt: "p" }, emits: [{ name: "pr", type: "pr" }] },
|
|
835
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge" } },
|
|
836
|
+
],
|
|
837
|
+
edges: [
|
|
838
|
+
{ from: "a.pr", to: "land" },
|
|
839
|
+
{ from: "b.pr", to: "land" },
|
|
840
|
+
],
|
|
841
|
+
};
|
|
842
|
+
const err = hasCode(validateDeliveryGraph(g), "unbound-pr");
|
|
843
|
+
assert(err.message.includes("disambiguate"), err.message);
|
|
844
|
+
});
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
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 { isConvergeTarget } from "./convergeTargets.ts";
|
|
23
|
+
|
|
22
24
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
23
25
|
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
24
26
|
* single source of truth for "which kinds are legal" so the validator and any future compiler agree. */
|
|
@@ -30,8 +32,11 @@ export type DeliveryNodeKind = (typeof DELIVERY_NODE_KINDS)[number];
|
|
|
30
32
|
/** The CLOSED emitted-fact type allowlist (ADR 0005 Decision 3/4) — mirrors the `DeliveryFact.type`
|
|
31
33
|
* enum in `openapi.yaml`. Kept as the single source of truth so the semantic validator rejects an
|
|
32
34
|
* untyped/unknown fact type even when the OpenAPI shape validator is bypassed (a directly-invoked
|
|
33
|
-
* delegate), since later compilation/execution steps rely on this allowlist.
|
|
34
|
-
|
|
35
|
+
* delegate), since later compilation/execution steps rely on this allowlist. `pr` (issue #548) is the
|
|
36
|
+
* PR-reference type an `agent` node emits for the PR it opened (`owner/repo#N`), so a downstream
|
|
37
|
+
* `connector[converge*]` / `wait[pr]` node LATE-BINDS its target PR from that fact instead of a
|
|
38
|
+
* hardcoded literal (the canonical `agent → connector[converge-merge] → wait[pr, merged]` shape). */
|
|
39
|
+
export const DELIVERY_FACT_TYPES = ["string", "number", "boolean", "artifact", "version", "url", "pr"] as const;
|
|
35
40
|
|
|
36
41
|
/** An emitted fact's declared `type`, narrowed to the closed allowlist. */
|
|
37
42
|
export type DeliveryFactType = (typeof DELIVERY_FACT_TYPES)[number];
|
|
@@ -84,7 +89,8 @@ export type DeliveryGraphErrorCode =
|
|
|
84
89
|
| "multiple-defaults"
|
|
85
90
|
| "non-exhaustive-split"
|
|
86
91
|
| "exclusive-merge-parity"
|
|
87
|
-
| "unsupported-on-timeout"
|
|
92
|
+
| "unsupported-on-timeout"
|
|
93
|
+
| "unbound-pr";
|
|
88
94
|
|
|
89
95
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
90
96
|
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
@@ -280,6 +286,19 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
280
286
|
// validation (pass 3) reads to enforce that a `when` references a SCALAR fact.
|
|
281
287
|
const nodeFacts = new Map<string, Set<string>>();
|
|
282
288
|
const nodeFactTypes = new Map<string, Map<string, DeliveryFactType>>();
|
|
289
|
+
// #548 PR late-binding: the converge-connector / pr-wait nodes whose target PR must resolve to a
|
|
290
|
+
// literal `owner/repo#N` OR a threaded upstream `pr` fact. Collected in pass 1, validated in pass 4
|
|
291
|
+
// once the incoming fact edges are known — so an author who references a `pr` fact that isn't
|
|
292
|
+
// actually threaded (or isn't `pr`-typed) is rejected at COMPILE, not left to fail closed at runtime.
|
|
293
|
+
const prBindConsumers: {
|
|
294
|
+
path: string;
|
|
295
|
+
field: "connector.payload.pr" | "wait.target";
|
|
296
|
+
id: string;
|
|
297
|
+
// The authored PR ref: the connector's `payload.pr` or the wait's `target`. `undefined` when
|
|
298
|
+
// absent (a connector may omit it and auto-bind the single incoming `pr` fact; a wait's `target`
|
|
299
|
+
// is a required field reported missing elsewhere).
|
|
300
|
+
authored: string | undefined;
|
|
301
|
+
}[] = [];
|
|
283
302
|
nodes.forEach((rawNode, i) => {
|
|
284
303
|
const path = `nodes[${i}]`;
|
|
285
304
|
if (!isRecord(rawNode)) {
|
|
@@ -358,6 +377,26 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
358
377
|
code: "unsupported-on-timeout",
|
|
359
378
|
});
|
|
360
379
|
}
|
|
380
|
+
// #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
|
|
381
|
+
// binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
|
|
382
|
+
if (typeof id === "string" && id.length > 0) {
|
|
383
|
+
if (kind === "connector" && typeof config.target === "string" && isConvergeTarget(config.target)) {
|
|
384
|
+
const payload = isRecord(config.payload) ? config.payload : undefined;
|
|
385
|
+
prBindConsumers.push({
|
|
386
|
+
path,
|
|
387
|
+
field: "connector.payload.pr",
|
|
388
|
+
id,
|
|
389
|
+
authored: payload !== undefined && typeof payload.pr === "string" ? payload.pr : undefined,
|
|
390
|
+
});
|
|
391
|
+
} else if (kind === "wait" && config.kind === "pr") {
|
|
392
|
+
prBindConsumers.push({
|
|
393
|
+
path,
|
|
394
|
+
field: "wait.target",
|
|
395
|
+
id,
|
|
396
|
+
authored: typeof config.target === "string" ? config.target : undefined,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
361
400
|
}
|
|
362
401
|
} else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
|
|
363
402
|
// `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
|
|
@@ -472,6 +511,9 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
472
511
|
}
|
|
473
512
|
// consumer (`to`) → set of upstream node ids (`from`'s node) — the dependency direction.
|
|
474
513
|
const adjacency = new Map<string, Set<string>>();
|
|
514
|
+
// #548: consumer (`to`) → the resolved fact edges threaded INTO it (`<node>.<fact>` refs + declared
|
|
515
|
+
// types), so pass 4 can confirm a converge/wait PR reference is actually threaded and `pr`-typed.
|
|
516
|
+
const incomingFactRefs = new Map<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>();
|
|
475
517
|
// Resolved, well-formed edges captured for the guard/topology pass (pass 3). Only edges whose BOTH
|
|
476
518
|
// endpoints resolve are kept — a dangling/self edge is already reported and must not reach pass 3.
|
|
477
519
|
const guardEdges: {
|
|
@@ -556,6 +598,13 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
556
598
|
const ups = adjacency.get(to) ?? new Set<string>();
|
|
557
599
|
ups.add(nodeId);
|
|
558
600
|
adjacency.set(to, ups);
|
|
601
|
+
// #548: record a resolved, DECLARED fact edge so pass 4 can confirm a converge/wait PR reference
|
|
602
|
+
// is actually threaded into its consumer (and is `pr`-typed).
|
|
603
|
+
if (fact !== undefined && upstreamFacts.has(fact)) {
|
|
604
|
+
const refs = incomingFactRefs.get(to) ?? [];
|
|
605
|
+
refs.push({ ref: `${nodeId}.${fact}`, factName: fact, factType: nodeFactTypes.get(nodeId)?.get(fact) });
|
|
606
|
+
incomingFactRefs.set(to, refs);
|
|
607
|
+
}
|
|
559
608
|
guardEdges.push({
|
|
560
609
|
index: i,
|
|
561
610
|
fromNode: nodeId,
|
|
@@ -570,14 +619,119 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
570
619
|
});
|
|
571
620
|
|
|
572
621
|
collectCycle(adjacency, errors);
|
|
573
|
-
//
|
|
574
|
-
//
|
|
622
|
+
// Passes 3 & 4 assume an otherwise structurally-sound graph (every edge resolved, no cycle) — a
|
|
623
|
+
// malformed base graph is reported first, and guard/binding analysis assumes a DAG with resolved
|
|
624
|
+
// fact edges. Both run under the SAME soundness snapshot so a pass-3 guard error can't suppress a
|
|
625
|
+
// pass-4 binding error (they are independent classes and should surface together).
|
|
575
626
|
if (errors.length === 0) {
|
|
627
|
+
// Pass 3: guard (S7) semantics.
|
|
576
628
|
validateGuardedEdges(guardEdges, nodeFactTypes, errors);
|
|
629
|
+
// Pass 4 (#548): converge/wait PR late-binding — a referenced PR fact must be threaded + `pr`-typed.
|
|
630
|
+
validatePrBindings(prBindConsumers, incomingFactRefs, nodeFacts, nodeFactTypes, errors);
|
|
577
631
|
}
|
|
578
632
|
return errors;
|
|
579
633
|
}
|
|
580
634
|
|
|
635
|
+
/** Pass 4 (#548) — validate that every converge-connector / pr-wait node can resolve its target PR.
|
|
636
|
+
* A converge/wait node needs a PR to enroll or poll; it may name it three ways (mirroring the runtime
|
|
637
|
+
* `resolveConvergePr` / the wait compiler `context put` late-bind):
|
|
638
|
+
* • a LITERAL `owner/repo#N` — never `<node>.<fact>`-shaped, so it resolves to no graph node and is
|
|
639
|
+
* accepted here (the worker/probe validates the literal at runtime);
|
|
640
|
+
* • a fact REFERENCE `<node>.pr` — REQUIRES that the referenced node declares a `pr`-typed fact of
|
|
641
|
+
* that name AND that a fact edge actually threads it into this consumer (else it can never bind);
|
|
642
|
+
* • OMITTED (connectors only) — auto-binds the single incoming `pr` fact, else the single incoming
|
|
643
|
+
* fact; rejected when there is no PR to bind or the choice is ambiguous.
|
|
644
|
+
* Rejecting these at compile (fail closed, path-qualified) turns a silent runtime "requires payload.pr"
|
|
645
|
+
* / unparseable-target failure into an actionable authoring error. */
|
|
646
|
+
function validatePrBindings(
|
|
647
|
+
consumers: readonly {
|
|
648
|
+
path: string;
|
|
649
|
+
field: "connector.payload.pr" | "wait.target";
|
|
650
|
+
id: string;
|
|
651
|
+
authored: string | undefined;
|
|
652
|
+
}[],
|
|
653
|
+
incomingFactRefs: ReadonlyMap<string, { ref: string; factName: string; factType: DeliveryFactType | undefined }[]>,
|
|
654
|
+
nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
655
|
+
nodeFactTypes: ReadonlyMap<string, ReadonlyMap<string, DeliveryFactType>>,
|
|
656
|
+
errors: DeliveryGraphError[],
|
|
657
|
+
): void {
|
|
658
|
+
for (const c of consumers) {
|
|
659
|
+
const incoming = incomingFactRefs.get(c.id) ?? [];
|
|
660
|
+
const errPath = `${c.path}.${c.field}`;
|
|
661
|
+
if (c.authored !== undefined) {
|
|
662
|
+
// `resolveFrom` returns a `fact` iff the reference's prefix is an existing node — so a real PR
|
|
663
|
+
// literal (`owner/repo#N`, no node-qualified dot) resolves to no fact and passes through.
|
|
664
|
+
const resolved = resolveFrom(c.authored, nodeFacts);
|
|
665
|
+
if (resolved.fact === undefined) continue; // a literal — validated at runtime.
|
|
666
|
+
const ref = `${resolved.nodeId}.${resolved.fact}`;
|
|
667
|
+
const declaredType = nodeFactTypes.get(resolved.nodeId)?.get(resolved.fact);
|
|
668
|
+
if (declaredType === undefined) {
|
|
669
|
+
errors.push({
|
|
670
|
+
path: errPath,
|
|
671
|
+
message:
|
|
672
|
+
`${c.id}'s PR reference "${c.authored}" points at fact "${resolved.fact}" that node ` +
|
|
673
|
+
`"${resolved.nodeId}" does not declare in its \`emits[]\``,
|
|
674
|
+
code: "unbound-pr",
|
|
675
|
+
});
|
|
676
|
+
} else if (declaredType !== "pr") {
|
|
677
|
+
errors.push({
|
|
678
|
+
path: errPath,
|
|
679
|
+
message:
|
|
680
|
+
`${c.id}'s PR reference "${c.authored}" resolves to a "${declaredType}" fact — a converge/` +
|
|
681
|
+
"wait PR binding must reference a `pr`-typed fact",
|
|
682
|
+
code: "unbound-pr",
|
|
683
|
+
});
|
|
684
|
+
} else if (!incoming.some((f) => f.ref === ref)) {
|
|
685
|
+
errors.push({
|
|
686
|
+
path: errPath,
|
|
687
|
+
message:
|
|
688
|
+
`${c.id}'s PR reference "${ref}" is not threaded into it — add a fact edge ` +
|
|
689
|
+
`{ from: "${ref}", to: "${c.id}" } so the emitted PR late-binds`,
|
|
690
|
+
code: "unbound-pr",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
// OMITTED. Only a connector may omit its PR (auto-bind); a wait's `target` is a required field
|
|
696
|
+
// already reported missing elsewhere, so skip it here to avoid a duplicate error.
|
|
697
|
+
if (c.field !== "connector.payload.pr") continue;
|
|
698
|
+
const prNamed = incoming.filter((f) => f.factName === "pr");
|
|
699
|
+
if (prNamed.length === 1) {
|
|
700
|
+
if (prNamed[0].factType !== "pr") {
|
|
701
|
+
errors.push({
|
|
702
|
+
path: errPath,
|
|
703
|
+
message:
|
|
704
|
+
`converge connector "${c.id}" auto-binds its single incoming \`pr\` fact, but that fact is ` +
|
|
705
|
+
`typed "${prNamed[0].factType}" — declare it as \`pr\` or set connector.payload.pr explicitly`,
|
|
706
|
+
code: "unbound-pr",
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
} else if (prNamed.length === 0 && incoming.length === 1) {
|
|
710
|
+
if (incoming[0].factType !== "pr") {
|
|
711
|
+
errors.push({
|
|
712
|
+
path: errPath,
|
|
713
|
+
message:
|
|
714
|
+
`converge connector "${c.id}" would auto-bind its single incoming fact "${incoming[0].ref}", ` +
|
|
715
|
+
`but it is typed "${incoming[0].factType}", not \`pr\` — thread a \`pr\` fact or set ` +
|
|
716
|
+
"connector.payload.pr to a literal or `<node>.pr` reference",
|
|
717
|
+
code: "unbound-pr",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
} else {
|
|
721
|
+
errors.push({
|
|
722
|
+
path: errPath,
|
|
723
|
+
message:
|
|
724
|
+
prNamed.length === 0
|
|
725
|
+
? `converge connector "${c.id}" has no target PR — set connector.payload.pr to a literal ` +
|
|
726
|
+
'"owner/repo#N" or a "<node>.pr" reference, or thread a single `pr` fact edge into it'
|
|
727
|
+
: `converge connector "${c.id}" has ${prNamed.length} incoming \`pr\` facts — disambiguate ` +
|
|
728
|
+
'by setting connector.payload.pr to the specific "<node>.pr" reference',
|
|
729
|
+
code: "unbound-pr",
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
581
735
|
/** True when a guard `equals` literal's JSON type matches the referenced fact's declared scalar type. */
|
|
582
736
|
function equalsMatchesFactType(equals: unknown, factType: DeliveryGuardScalarType): boolean {
|
|
583
737
|
switch (factType) {
|
|
@@ -252,7 +252,7 @@ test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last
|
|
|
252
252
|
// Discrete diagnostic task variables the form/agent can bind directly.
|
|
253
253
|
assert(esc.includes('target="probeDetail"'), "the escalation surfaces the probe's last detail as a discrete variable");
|
|
254
254
|
assert(esc.includes('target="observedReleases"'), "the escalation surfaces the observed candidate releases");
|
|
255
|
-
assert(/source="=nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved probe target");
|
|
255
|
+
assert(/source="=if \(is defined\(probe\.target\)\) then probe\.target else nodeInputs\.[^"]+\.probe\.target" target="probeTarget"/.test(esc), "the escalation surfaces the resolved (late-bound) probe target");
|
|
256
256
|
assert(/source="=nodeInputs\.[^"]+\.probe\.match" target="probeMatch"/.test(esc), "the escalation surfaces the resolved probe match");
|
|
257
257
|
});
|
|
258
258
|
|
|
@@ -423,6 +423,56 @@ test("converge-merge worked graph: agent → connector[converge-merge] → wait[
|
|
|
423
423
|
assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
+
test("#548 no-literal converge shape: an emitted `pr` fact late-binds the connector (boundFacts) AND the wait target (context put)", async () => {
|
|
427
|
+
// The canonical `agent → connector[converge-merge] → wait[pr, merged]` shape carrying NO hardcoded PR
|
|
428
|
+
// number: `open` emits the PR it opened as a typed `pr` fact, and both downstream consumers reference
|
|
429
|
+
// it (`open.pr`) on incoming fact edges. The compiler must (a) publish the fact as `<open>_pr`, (b)
|
|
430
|
+
// thread it into the connector's `boundFacts` input, and (c) rewrite the wait's probe target via
|
|
431
|
+
// `context put` to poll the late-bound PR.
|
|
432
|
+
const graph = {
|
|
433
|
+
name: "no-literal converge",
|
|
434
|
+
nodes: [
|
|
435
|
+
{
|
|
436
|
+
id: "open",
|
|
437
|
+
kind: "agent",
|
|
438
|
+
agent: { jobType: "senior:feature", prompt: "Implement and open a PR." },
|
|
439
|
+
emits: [{ name: "pr", type: "pr" }],
|
|
440
|
+
},
|
|
441
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" } } },
|
|
442
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" } } },
|
|
443
|
+
],
|
|
444
|
+
edges: [
|
|
445
|
+
{ from: "open.pr", to: "land" },
|
|
446
|
+
{ from: "open.pr", to: "merged" },
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
const r = await compileOk(graph);
|
|
450
|
+
const openEl = r.resolved.edges.find((e) => e.from === "open.pr")?.fromNode;
|
|
451
|
+
assertEquals(openEl, "open", "the fact edge resolves to the `open` producer node");
|
|
452
|
+
// (a) the producer publishes its declared `pr` emit into a flat `<element>_pr` parent variable.
|
|
453
|
+
assert(/target="[^"]*_pr"/.test(r.bpmn), "the agent's `pr` emit is published as `<element>_pr`");
|
|
454
|
+
// (b) the connector receives the fact list — its `boundFacts` input names the `pr` fact + producer.
|
|
455
|
+
assert(/target="boundFacts"/.test(r.bpmn), "the connector is threaded a boundFacts input");
|
|
456
|
+
assert(/name: "pr"/.test(r.bpmn), "the boundFacts entry names the `pr` fact");
|
|
457
|
+
// (c) the wait probe target is late-bound via `context put`, not the raw `open.pr` reference literal.
|
|
458
|
+
assert(/context put\([^)]*\.probe, "target",/.test(r.bpmn), "the wait probe target is rewritten via context put");
|
|
459
|
+
assert(!/target="owner\/repo#/.test(r.bpmn), "no hardcoded PR literal is compiled into the graph");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("a wait node with a LITERAL pr target compiles the probe unchanged (no spurious context put)", async () => {
|
|
463
|
+
const graph = {
|
|
464
|
+
name: "literal target",
|
|
465
|
+
nodes: [
|
|
466
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "open a PR" } },
|
|
467
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#7", match: { prState: "merged" } } },
|
|
468
|
+
],
|
|
469
|
+
edges: [{ from: "open", to: "merged" }],
|
|
470
|
+
};
|
|
471
|
+
const r = await compileOk(graph);
|
|
472
|
+
assert(!/context put/.test(r.bpmn), "a literal target is not wrapped in a context put rewrite");
|
|
473
|
+
assert(/source="=nodeInputs\.[^"]+\.probe" target="probe"/.test(r.bpmn), "the probe is seeded directly from nodeInputs");
|
|
474
|
+
});
|
|
475
|
+
|
|
426
476
|
test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
|
|
427
477
|
const r = await compileOk(RELEASE_RUNBOOK);
|
|
428
478
|
const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
|
|
@@ -822,12 +822,30 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
822
822
|
// threads null rather than raising a FEEL error.
|
|
823
823
|
inputs.push({ source: guarded(TRANSCRIPT_URL_BASE_VAR), target: TRANSCRIPT_URL_BASE_VAR });
|
|
824
824
|
break;
|
|
825
|
-
case "wait":
|
|
825
|
+
case "wait": {
|
|
826
826
|
inputs.push({ source: cfg("gateKey"), target: "gateKey" });
|
|
827
|
-
|
|
827
|
+
// #548 late-binding: when the authored probe `target` is a `<node>.<fact>` reference to an
|
|
828
|
+
// upstream emitted fact threaded on an incoming edge, rewrite the seeded probe's `target` to the
|
|
829
|
+
// OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
|
|
830
|
+
// wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
|
|
831
|
+
// target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
|
|
832
|
+
// passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
|
|
833
|
+
// authored value rather than raising a FEEL error.
|
|
834
|
+
const boundTarget = boundInputs.find((b) => `${b.fromNode}.${b.fact}` === node.wait.target);
|
|
835
|
+
if (boundTarget) {
|
|
836
|
+
const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
|
|
837
|
+
const probeRef = cfg("probe").slice(1);
|
|
838
|
+
inputs.push({
|
|
839
|
+
source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else ${probeRef}.target)`,
|
|
840
|
+
target: "probe",
|
|
841
|
+
});
|
|
842
|
+
} else {
|
|
843
|
+
inputs.push({ source: cfg("probe"), target: "probe" });
|
|
844
|
+
}
|
|
828
845
|
inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
|
|
829
846
|
inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
|
|
830
847
|
break;
|
|
848
|
+
}
|
|
831
849
|
case "human":
|
|
832
850
|
inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
|
|
833
851
|
inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
|
|
@@ -983,7 +1001,7 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
983
1001
|
const diagnosticInputs = [
|
|
984
1002
|
{ source: "=if (is defined(detail)) then detail else null", target: "probeDetail" },
|
|
985
1003
|
{ source: "=if (is defined(observed)) then observed else null", target: "observedReleases" },
|
|
986
|
-
{ source: `=nodeInputs.${el}.probe.target`, target: "probeTarget" },
|
|
1004
|
+
{ source: `=if (is defined(probe.target)) then probe.target else nodeInputs.${el}.probe.target`, target: "probeTarget" },
|
|
987
1005
|
{ source: `=nodeInputs.${el}.probe.match`, target: "probeMatch" },
|
|
988
1006
|
];
|
|
989
1007
|
return [
|
|
@@ -113,9 +113,11 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
|
|
|
113
113
|
> PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
|
|
114
114
|
> double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
|
|
115
115
|
> yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
|
|
116
|
-
> wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the
|
|
117
|
-
>
|
|
118
|
-
>
|
|
116
|
+
> wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
|
|
117
|
+
> may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
|
|
118
|
+
> node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
|
|
119
|
+
> shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
|
|
120
|
+
> in the compiler via FEEL `context put`). Other connector targets remain the forward-declared stub.
|
|
119
121
|
|
|
120
122
|
Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
|
|
121
123
|
sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
|
package/docs/agent-guide.md
CHANGED
|
@@ -597,28 +597,41 @@ into the PR's merge-stage dependency set. The enrollment is idempotent (the conn
|
|
|
597
597
|
at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
|
|
598
598
|
redelivery never double-enrolls.
|
|
599
599
|
|
|
600
|
-
**Canonical shape** — the agent opens the PR,
|
|
601
|
-
|
|
600
|
+
**Canonical shape** — the agent opens the PR, emits it as a typed **`pr` fact**, and the connector
|
|
601
|
+
and `wait[pr, merged]` gate **late-bind** that fact (no hardcoded PR number, no human node). The
|
|
602
|
+
author never knows the PR number at compose time, so reference it by fact:
|
|
602
603
|
|
|
603
604
|
```json
|
|
604
605
|
{
|
|
605
606
|
"name": "open → converge+merge → wait merged",
|
|
606
607
|
"nodes": [
|
|
607
608
|
{ "id": "open", "kind": "agent",
|
|
608
|
-
"agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." }
|
|
609
|
+
"agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." },
|
|
610
|
+
"emits": [ { "name": "pr", "type": "pr" } ] },
|
|
609
611
|
{ "id": "land", "kind": "connector",
|
|
610
|
-
"connector": { "target": "converge-merge", "payload": { "pr": "
|
|
612
|
+
"connector": { "target": "converge-merge", "payload": { "pr": "open.pr" } } },
|
|
611
613
|
{ "id": "merged", "kind": "wait",
|
|
612
|
-
"wait": { "kind": "pr", "target": "
|
|
614
|
+
"wait": { "kind": "pr", "target": "open.pr", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
|
|
613
615
|
],
|
|
614
616
|
"edges": [
|
|
615
|
-
{ "from": "open", "to": "land" },
|
|
616
|
-
{ "from": "
|
|
617
|
+
{ "from": "open.pr", "to": "land" },
|
|
618
|
+
{ "from": "open.pr", "to": "merged" }
|
|
617
619
|
]
|
|
618
620
|
}
|
|
619
621
|
```
|
|
620
622
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
623
|
+
The `pr` fact is threaded along the **fact-qualified edges** (`open.pr → land`, `open.pr → merged`) —
|
|
624
|
+
those edges are what carry the observed PR into each consumer, so they are **required** when you
|
|
625
|
+
reference `open.pr` (the validator rejects a reference with no threading edge, `unbound-pr`). Three
|
|
626
|
+
ways to name the target PR:
|
|
627
|
+
|
|
628
|
+
- **fact reference** `"<node>.pr"` — late-bound from the upstream `agent`'s emitted `pr` fact (the
|
|
629
|
+
shape above). The referenced fact must be declared **`pr`-typed** and threaded on an incoming edge.
|
|
630
|
+
- **omitted** (connector only) — `payload` without a `pr` auto-binds the **single** incoming `pr`
|
|
631
|
+
fact, so `"connector": { "target": "converge-merge" }` works when exactly one `pr` fact flows in.
|
|
632
|
+
- **literal** `"owner/repo#N"` — still accepted for a PR you already know (a real ref is never
|
|
633
|
+
`<node>.pr`-shaped, so it never collides with a reference).
|
|
634
|
+
|
|
635
|
+
`senior:feature` already returns the PR it opened, so declaring `emits: [{ "name": "pr", "type": "pr" }]`
|
|
636
|
+
on the agent node is all it takes to publish it (issue #548).
|
|
624
637
|
|
|
@@ -155,6 +155,36 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
|
|
|
155
155
|
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the graph reached its End event");
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
test("#548 wait target LATE-BINDS from an upstream fact via FEEL `context put` (engine-native)", async () => {
|
|
159
|
+
const app = track(await boot(freshDir()));
|
|
160
|
+
|
|
161
|
+
// The agent emits a `cmd` fact whose OBSERVED value is the deterministic green probe command
|
|
162
|
+
// ("true"). The downstream wait references that fact as its probe `target` (`a.cmd`) — the compiler
|
|
163
|
+
// rewrites the seeded probe's target via FEEL `context put`, so the probe polls the LATE-BOUND
|
|
164
|
+
// value, not a hardcoded literal. This is the exact mechanism the canonical `agent →
|
|
165
|
+
// connector[converge-merge] → wait[pr, merged]` shape uses to poll the PR the agent opened (#548).
|
|
166
|
+
await app.engine.registerWorker("senior:demo", async () => ({ cmd: "true" }));
|
|
167
|
+
|
|
168
|
+
const graph: DeliveryGraph = {
|
|
169
|
+
name: "e2e wait late-bind",
|
|
170
|
+
nodes: [
|
|
171
|
+
{ id: "a", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "cmd", type: "string" }] },
|
|
172
|
+
{ id: "w", kind: "wait", wait: { kind: "command", target: "a.cmd", poll: { everyMs: 5, backoff: "fixed" } } },
|
|
173
|
+
],
|
|
174
|
+
edges: [{ from: "a.cmd", to: "w" }],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
|
|
178
|
+
assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
|
|
179
|
+
await app.settle();
|
|
180
|
+
|
|
181
|
+
// The wait's probe ran the LATE-BOUND command "true" (green) — had `context put` not resolved the
|
|
182
|
+
// `a.cmd` reference to "true", the probe would have run the literal "a.cmd" (a probe escape the
|
|
183
|
+
// hermetic seam records + fails on), and the gate would never have resolved. The graph reaching End
|
|
184
|
+
// proves the wait released its ready branch off the late-bound target.
|
|
185
|
+
assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
|
|
186
|
+
});
|
|
187
|
+
|
|
158
188
|
test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
|
|
159
189
|
const app = track(await boot(freshDir()));
|
|
160
190
|
// The connector fired once above's-style; here prove the idempotency directly against the ledger a
|
package/openapi.yaml
CHANGED
|
@@ -1361,10 +1361,13 @@ components:
|
|
|
1361
1361
|
description: The fact's identifier, referenced downstream as `<nodeId>.<name>`. Must be unique within the node.
|
|
1362
1362
|
type:
|
|
1363
1363
|
type: string
|
|
1364
|
-
enum: [string, number, boolean, artifact, version, url]
|
|
1364
|
+
enum: [string, number, boolean, artifact, version, url, pr]
|
|
1365
1365
|
description: >-
|
|
1366
1366
|
The fact's declared type. `artifact` is a `pkg@version` handle, `version` a bare version
|
|
1367
|
-
string, `url` a location — mirrors the values `capability`/`pr` probes late-bind.
|
|
1367
|
+
string, `url` a location — mirrors the values `capability`/`pr` probes late-bind. `pr`
|
|
1368
|
+
(issue #548) is a PR reference (`owner/repo#N`) an `agent` node emits for the PR it opened,
|
|
1369
|
+
so a downstream `connector[converge*]`/`wait[pr]` node LATE-BINDS its target PR from the
|
|
1370
|
+
fact instead of a hardcoded literal.
|
|
1368
1371
|
description:
|
|
1369
1372
|
type: string
|
|
1370
1373
|
maxLength: 512
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.142.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -123,34 +123,52 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
|
|
|
123
123
|
|
|
124
124
|
test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
|
|
125
125
|
// `converge-merge` drives the merge loop → convergeOnly defaults false.
|
|
126
|
-
const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
|
|
126
|
+
const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" }, null);
|
|
127
127
|
assertEquals(merge.parsed.prKey, "owner/repo#7");
|
|
128
128
|
assertEquals(merge.convergeOnly, false);
|
|
129
129
|
assertEquals(merge.dependsOn, []);
|
|
130
130
|
// `converge` is review-only → convergeOnly defaults true.
|
|
131
|
-
const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
|
|
131
|
+
const conv = readConvergeInput("converge", { pr: "owner/repo#7" }, null);
|
|
132
132
|
assertEquals(conv.convergeOnly, true);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
|
|
136
|
-
const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
|
|
136
|
+
const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] }, null);
|
|
137
137
|
assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
|
|
138
138
|
assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
|
|
142
|
-
assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
|
|
143
|
-
assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
|
|
144
|
-
assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
|
|
142
|
+
assertThrows(() => readConvergeInput("converge-merge", null, null), Error, "payload.pr");
|
|
143
|
+
assertThrows(() => readConvergeInput("converge-merge", {}, null), Error, "payload.pr");
|
|
144
|
+
assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }, null), Error, "payload.pr");
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
|
|
148
148
|
// `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
|
|
149
149
|
// throw, which must NOT mask the intended "requires payload.pr" error.
|
|
150
|
-
assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
|
|
150
|
+
assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }, null), Error, "payload.pr");
|
|
151
151
|
const circular: Record<string, unknown> = {};
|
|
152
152
|
circular.self = circular;
|
|
153
|
-
assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
|
|
153
|
+
assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }, null), Error, "payload.pr");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("readConvergeInput: #548 late-binds the PR from an upstream agent's emitted `pr` fact (no literal needed)", () => {
|
|
157
|
+
const bound = [{ from: "open", name: "pr", value: "owner/repo#42" }];
|
|
158
|
+
// OMITTED payload.pr → binds the single incoming `pr` fact.
|
|
159
|
+
assertEquals(readConvergeInput("converge-merge", null, bound).parsed.prKey, "owner/repo#42");
|
|
160
|
+
assertEquals(readConvergeInput("converge-merge", {}, bound).parsed.prKey, "owner/repo#42");
|
|
161
|
+
// EXPLICIT reference form → resolves that specific threaded fact.
|
|
162
|
+
assertEquals(readConvergeInput("converge", { pr: "open.pr" }, bound).parsed.prKey, "owner/repo#42");
|
|
163
|
+
// A LITERAL still wins as-is even when a bound fact is present (never `<node>.<fact>`-shaped).
|
|
164
|
+
assertEquals(readConvergeInput("converge", { pr: "owner/repo#9" }, bound).parsed.prKey, "owner/repo#9");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("readConvergeInput: #548 a dangling reference (no matching bound fact) fails CLOSED", () => {
|
|
168
|
+
// "open.pr" is reference-shaped but nothing threaded it → falls through to a literal parse, which
|
|
169
|
+
// rejects (it is not an `owner/repo#N`), so a mis-wired graph never silently enrolls a junk PR.
|
|
170
|
+
assertThrows(() => readConvergeInput("converge-merge", { pr: "open.pr" }, []), Error, "payload.pr");
|
|
171
|
+
assertThrows(() => readConvergeInput("converge-merge", { pr: "open.pr" }, [{ from: "other", name: "pr", value: "o/r#1" }]), Error, "payload.pr");
|
|
154
172
|
});
|
|
155
173
|
|
|
156
174
|
test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
convergeOnlyForTarget,
|
|
16
16
|
dispatchConnector,
|
|
17
17
|
isConvergeTarget,
|
|
18
|
+
resolveConvergePr,
|
|
18
19
|
} from "../../app/deliveryConnector.ts";
|
|
19
20
|
import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
|
|
20
21
|
|
|
@@ -81,19 +82,26 @@ export function safeStringify(value: unknown): string {
|
|
|
81
82
|
* `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
|
|
82
83
|
* the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
|
|
83
84
|
* optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
|
|
84
|
-
* entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage
|
|
85
|
-
*
|
|
86
|
-
*
|
|
85
|
+
* entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.
|
|
86
|
+
*
|
|
87
|
+
* `pr` may be sourced three ways (issue #548), resolved by {@link resolveConvergePr} against the
|
|
88
|
+
* threaded `boundFacts` BEFORE parsing: a LITERAL `owner/repo#N`, an explicit fact REFERENCE
|
|
89
|
+
* (`payload.pr: "<upstreamNode>.pr"`), or OMITTED — late-bound from the single incoming `pr` fact an
|
|
90
|
+
* upstream `agent` node emitted for the PR it opened. This is what lets the canonical
|
|
91
|
+
* `agent → connector[converge-merge] → wait` shape carry no hardcoded PR number. */
|
|
87
92
|
export function readConvergeInput(
|
|
88
93
|
target: string,
|
|
89
94
|
payload: Record<string, unknown> | null,
|
|
95
|
+
boundFacts: readonly BoundFact[] | null,
|
|
90
96
|
): { parsed: ParsedPr; convergeOnly: boolean; dependsOn: string[] } {
|
|
91
97
|
const p = payload ?? {};
|
|
92
|
-
const
|
|
98
|
+
const prValue = resolveConvergePr(p.pr, boundFacts ?? []);
|
|
99
|
+
const parsed = parsePr(prValue);
|
|
93
100
|
if (!parsed) {
|
|
94
101
|
throw new Error(
|
|
95
|
-
`delivery-connector: '${target}' target requires
|
|
96
|
-
`
|
|
102
|
+
`delivery-connector: '${target}' target requires a target PR — a literal "owner/repo#N" in ` +
|
|
103
|
+
`payload.pr, an upstream-fact reference (payload.pr: "<node>.pr"), or a threaded \`pr\` fact ` +
|
|
104
|
+
`bound from an upstream \`agent\` node (got ${safeStringify(prValue ?? null)})`,
|
|
97
105
|
);
|
|
98
106
|
}
|
|
99
107
|
const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
|
|
@@ -106,7 +114,7 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
|
|
|
106
114
|
// A `converge`/`converge-merge` target enrolls a PR into the shared convergence (+ merge) loop.
|
|
107
115
|
// Parse + validate its payload BEFORE claiming a ledger row, so a misconfigured converge node (no
|
|
108
116
|
// parseable `pr`) fails CLOSED without writing a junk dispatch row it could never act on.
|
|
109
|
-
const converge = isConvergeTarget(target) ? readConvergeInput(target, payload) : null;
|
|
117
|
+
const converge = isConvergeTarget(target) ? readConvergeInput(target, payload, boundFacts) : null;
|
|
110
118
|
const dedupeKey = connectorDedupeKey({
|
|
111
119
|
dedupeKey: job.variables.dedupeKey ?? null,
|
|
112
120
|
processInstanceKey: job.processInstanceKey ?? null,
|