@nanobpm/nano-workforce 0.140.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 +12 -0
- package/app/agentic/channel.ts +15 -1
- package/app/agentic/correlation-store.test.ts +105 -12
- package/app/agentic/correlation-store.ts +64 -10
- package/app/agentic/correlation.test.ts +46 -0
- package/app/agentic/correlation.ts +41 -4
- package/app/agentic/element-instance.test.ts +111 -0
- package/app/agentic/element-instance.ts +97 -0
- package/app/agentic/families/relay.family.test.ts +90 -0
- package/app/agentic/families/relay.family.ts +84 -0
- package/app/agentic/registry.ts +17 -3
- package/app/agentic/transcript-read.test.ts +27 -0
- package/app/agentic/transcript-read.ts +11 -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/db/migrations/086_agentic_correlation_element_instance.sql +21 -0
- 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/main.ts +6 -0
- package/openapi.yaml +21 -2
- package/operations/listAgenticTranscripts.ts +1 -0
- package/package.json +2 -2
- package/workers/delivery-connector/worker.test.ts +26 -8
- package/workers/delivery-connector/worker.ts +15 -7
|
@@ -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 [
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Key the durable agent correlation on the ELEMENT INSTANCE, not just the static BPMN element id
|
|
2
|
+
-- (#544, Stage 1 of transcript↔process-run correlation; ADR 0006 §4b intersection, #464).
|
|
3
|
+
--
|
|
4
|
+
-- `078_agentic_correlation.sql` records `element_id` — the STATIC BPMN id — which is ambiguous across
|
|
5
|
+
-- a looping / retried job: the same activity id occupies many distinct element instances over a
|
|
6
|
+
-- process instance's life, so a transcript keyed only by `element_id` cannot say WHICH occupancy a
|
|
7
|
+
-- token was in. `element_instance_key` is the engine's per-occupancy handle (the same one Nano
|
|
8
|
+
-- Explorer addresses runtime position by, and the one Camunda keys its agent model on), resolved from
|
|
9
|
+
-- the agent job's `jobKey` via the engine element-instance wait-state read (nano-ide#473's binding).
|
|
10
|
+
--
|
|
11
|
+
-- Expand-and-contract: this is the EXPAND step — a nullable, additive column alongside the retained
|
|
12
|
+
-- `element_id` (kept during the transition, never dropped here). NULL for pre-#544 rows and whenever
|
|
13
|
+
-- the (advisory, best-effort) resolution did not land, so it never gates a BPMN sequence flow.
|
|
14
|
+
--
|
|
15
|
+
-- Single source of truth: the durable table's canonical DDL is `AGENTIC_CORRELATION_SCHEMA_SQL` in
|
|
16
|
+
-- `app/agentic/correlation-store.ts` (applied idempotently at store construction). This migration
|
|
17
|
+
-- brings an already-078-migrated DB up to that same effective shape; a drift-guard test
|
|
18
|
+
-- (`correlation-store.test.ts`) pins the migrated schema (078 + 086) to the canonical DDL so the two
|
|
19
|
+
-- can never diverge.
|
|
20
|
+
ALTER TABLE agentic_correlation ADD COLUMN element_instance_key TEXT;
|
|
21
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_element_instance ON agentic_correlation (element_instance_key);
|
|
@@ -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/main.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { Server } from "node:http";
|
|
21
21
|
import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
|
|
22
22
|
import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
|
|
23
|
+
import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
|
|
23
24
|
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
24
25
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
25
26
|
import { envVar } from "./app/version.ts";
|
|
@@ -79,6 +80,11 @@ if (httpServer instanceof Server) {
|
|
|
79
80
|
secret: agenticSecret ?? "",
|
|
80
81
|
secure,
|
|
81
82
|
data: app.data,
|
|
83
|
+
// #544: advisory, read-only element-instance resolution over the shared engine's wait-state
|
|
84
|
+
// read model, so the relay slice can key a captured agent session on the element INSTANCE it
|
|
85
|
+
// occupied (unambiguous across a looping / retried job), not just the static element id. A
|
|
86
|
+
// narrow closure — the agentic families never hold the engine handle itself.
|
|
87
|
+
resolveElementInstance: makeElementInstanceResolver(engine),
|
|
82
88
|
log: app.log,
|
|
83
89
|
});
|
|
84
90
|
if (!secure) {
|