@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.
@@ -0,0 +1,97 @@
1
+ // nano-workforce — resolve an agent job's ENGINE ELEMENT-INSTANCE KEY from its jobKey (#544).
2
+ //
3
+ // The durable correlation store (`./correlation-store.ts`) has historically keyed an agent session's
4
+ // engine context on the STATIC BPMN `element_id`. That id is ambiguous across a looping / retried
5
+ // activity: every re-activation of the same task id is a DISTINCT element instance sharing one id, so
6
+ // a transcript keyed on `element_id` alone cannot say WHICH occupancy produced it. #544 keys on the
7
+ // engine's per-occupancy handle instead — the `elementInstanceKey` — which is exactly what Nano
8
+ // Explorer addresses runtime position by and what Camunda keys its agent model on.
9
+ //
10
+ // The engine does not offer a `getJob(jobKey)` lookup, but it DOES surface every parked element
11
+ // instance via the element-instance wait-state read (`POST /v2/element-instances/wait-states/search`,
12
+ // bound onto the `@nanobpm/urban` EngineClient by nano-ide#473). A service task awaiting a worker is a
13
+ // `JOB` park, and that park carries BOTH its `jobKey` AND its owning `elementInstanceKey`. So the join
14
+ // is: list the live JOB parks, find the one whose `jobKey` matches the agent job, and read off its
15
+ // `elementInstanceKey`. Because a park is keyed to a specific element instance, this is unambiguous
16
+ // even when many iterations of the same static element are (or have been) live — each iteration is a
17
+ // separate park with a separate jobKey (see the looping/retried-job test).
18
+ //
19
+ // Invariant fit (ADR 0056): this is an ADVISORY, READ-ONLY engine query — it observes the engine's
20
+ // read model to enrich a visibility record. It NEVER activates/completes a job, publishes a message,
21
+ // or gates a BPMN sequence flow; the Camunda-8 job protocol (worker⇄engine) is untouched. It is
22
+ // deliberately expressed against a narrow reader shape (not the whole EngineClient) so the callers
23
+ // that drive it stay structurally decoupled from the engine.
24
+ import type { ElementInstanceWaitState, ElementInstanceWaitStateFilter } from "@nanobpm/urban";
25
+
26
+ /**
27
+ * The narrow slice of the engine read model this resolver needs: the element-instance wait-state
28
+ * search. `@nanobpm/urban`'s `EngineClient` satisfies it structurally; a test supplies a fake.
29
+ */
30
+ export interface ElementInstanceWaitStateReader {
31
+ searchElementInstanceWaitStates(
32
+ filter?: ElementInstanceWaitStateFilter,
33
+ ): Promise<readonly ElementInstanceWaitState[]>;
34
+ }
35
+
36
+ /** Optional scoping for {@link resolveElementInstanceKey} (a performance narrowing, never required). */
37
+ export interface ResolveElementInstanceOptions {
38
+ /**
39
+ * The owning process instance, when the caller already knows it. Passed to the engine as a search
40
+ * filter so the read is scoped to one process instance rather than every live JOB park. It is a pure
41
+ * OPTIMISATION: the `jobKey` is the match key and is engine-unique, so an unscoped search resolves
42
+ * the same element instance — just over a larger candidate set.
43
+ */
44
+ readonly processInstanceKey?: string;
45
+ }
46
+
47
+ /**
48
+ * Resolve the `elementInstanceKey` the agent job identified by `jobKey` occupies, by matching the
49
+ * job against the engine's live `JOB` wait-state parks. Returns `undefined` when the job is not (or no
50
+ * longer) parked — e.g. it already completed and released its park, or the jobKey is empty — which the
51
+ * advisory callers treat as "not resolved", never an error.
52
+ *
53
+ * The resolution keys on `jobKey`, NOT `elementId`: that is the whole point of #544. A retried /
54
+ * looping activity has many parks sharing one `elementId` but each with its own `jobKey` and its own
55
+ * `elementInstanceKey`, so matching on `jobKey` returns the correct per-occupancy instance.
56
+ */
57
+ export async function resolveElementInstanceKey(
58
+ reader: ElementInstanceWaitStateReader,
59
+ jobKey: string,
60
+ options: ResolveElementInstanceOptions = {},
61
+ ): Promise<string | undefined> {
62
+ if (jobKey === "") return undefined;
63
+ const filter: ElementInstanceWaitStateFilter = { waitStateType: "JOB" };
64
+ if (options.processInstanceKey !== undefined && options.processInstanceKey !== "") {
65
+ filter.processInstanceKey = options.processInstanceKey;
66
+ }
67
+ const parks = await reader.searchElementInstanceWaitStates(filter);
68
+ for (const park of parks) {
69
+ // Narrow to the JOB variant (the discriminant guards `jobKey`); a non-JOB park never carries the
70
+ // jobKey field even if the engine ignored the filter. Match on the engine-unique jobKey.
71
+ if (park.waitStateType === "JOB" && park.jobKey === jobKey) {
72
+ return park.elementInstanceKey;
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * The narrow, advisory element-instance resolution seam the relay slice fires at link time (#544):
80
+ * given an agent `jobKey` (and its owning `processInstanceKey` when known), resolve the engine
81
+ * element-instance key it occupies, or `undefined` when it is not resolvable. A function shape — NOT
82
+ * an `EngineClient` — so the agentic families depend on a capability, not the engine itself; the
83
+ * composition root ({@link file://main.ts}) closes it over the real engine, a test over a fake.
84
+ */
85
+ export type ElementInstanceResolver = (
86
+ jobKey: string,
87
+ processInstanceKey?: string,
88
+ ) => Promise<string | undefined>;
89
+
90
+ /**
91
+ * Build an {@link ElementInstanceResolver} bound to an engine wait-state reader — the closure the
92
+ * composition root threads into the agentic channel so the relay slice can resolve an agent job's
93
+ * element instance without holding an engine reference of its own.
94
+ */
95
+ export function makeElementInstanceResolver(reader: ElementInstanceWaitStateReader): ElementInstanceResolver {
96
+ return (jobKey, processInstanceKey) => resolveElementInstanceKey(reader, jobKey, { processInstanceKey });
97
+ }
@@ -134,6 +134,12 @@ function mkService(registry: ConnectionRegistry, db: SqliteDb | undefined): {
134
134
  return { service, hub };
135
135
  }
136
136
 
137
+ /** Flush the microtask/macrotask queue so a fire-and-forget promise chain (the async #544 element-
138
+ * instance resolution) settles before the test asserts on its effects. */
139
+ function tick(): Promise<void> {
140
+ return new Promise((resolve) => setTimeout(resolve, 0));
141
+ }
142
+
137
143
  /** Build a service with the H6 correlation write-side wired (a real registry + a connection→instance map). */
138
144
  function mkCorrelatedService(
139
145
  registry: ConnectionRegistry,
@@ -143,6 +149,7 @@ function mkCorrelatedService(
143
149
  extra: {
144
150
  attributionForInstance?: (instance: string) => { identity?: string; host?: string } | undefined;
145
151
  correlationStore?: AgenticCorrelationStore;
152
+ resolveElementInstance?: (jobKey: string, processInstanceKey?: string) => Promise<string | undefined>;
146
153
  now?: () => string;
147
154
  } = {},
148
155
  ): { service: RelayTranscriptService; hub: CapturingHub } {
@@ -156,6 +163,7 @@ function mkCorrelatedService(
156
163
  instanceForConnection: (id) => byConnection.get(id),
157
164
  attributionForInstance: extra.attributionForInstance,
158
165
  correlationStore: extra.correlationStore,
166
+ resolveElementInstance: extra.resolveElementInstance,
159
167
  now: extra.now,
160
168
  });
161
169
  return { service, hub };
@@ -452,6 +460,88 @@ test("H6 durable attribution: completing/superseding a job persists the worker's
452
460
  service.teardown();
453
461
  });
454
462
 
463
+ test("#544 element-instance enrichment: link-time resolution keys the completed session on the element instance", async () => {
464
+ const registry = new ConnectionRegistry();
465
+ const correlation = new CorrelationRegistry();
466
+ const db = memoryDb();
467
+ const store = new AgenticCorrelationStore(db);
468
+ const byConnection = new Map([["prod", "worker-A"]]);
469
+ // The engine resolves job k1's live JOB park to element instance ei-1 (resolution wins the race,
470
+ // i.e. it returns while the job is still live — the common case for a long-lived agent job).
471
+ const resolveElementInstance = (jobKey: string) =>
472
+ Promise.resolve(jobKey === "k1" ? "ei-1" : undefined);
473
+ const { service, hub } = mkCorrelatedService(registry, db, correlation, byConnection, {
474
+ correlationStore: store,
475
+ resolveElementInstance,
476
+ now: () => "2024-01-02T03:04:05.000Z",
477
+ });
478
+ const p = connect("prod", registry);
479
+
480
+ // First produce links the job and fires the (async) element-instance resolution.
481
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
482
+ await tick();
483
+ // The live correlation context is enriched while the job runs.
484
+ assertEquals(correlation.resolve("k1")?.elementInstanceKey, "ei-1", "the live context carries the element instance");
485
+
486
+ // Superseding with a new job completes k1 → its attribution persists WITH the element-instance key.
487
+ hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
488
+ const durable = store.get("k1");
489
+ assertEquals(durable?.elementInstanceKey, "ei-1", "the completed session is keyed on the element instance");
490
+ service.teardown();
491
+ });
492
+
493
+ test("#544 element-instance enrichment: a resolution that lands AFTER completion backfills the durable row", async () => {
494
+ const registry = new ConnectionRegistry();
495
+ const correlation = new CorrelationRegistry();
496
+ const db = memoryDb();
497
+ const store = new AgenticCorrelationStore(db);
498
+ const byConnection = new Map([["prod", "worker-A"]]);
499
+ // A deferred resolution the test releases MANUALLY, to force the race where the element-instance
500
+ // key arrives only after the job already completed and released its live correlation.
501
+ let release: (key: string | undefined) => void = () => {};
502
+ const pending = new Promise<string | undefined>((resolve) => {
503
+ release = resolve;
504
+ });
505
+ const { service, hub } = mkCorrelatedService(registry, db, correlation, byConnection, {
506
+ correlationStore: store,
507
+ resolveElementInstance: () => pending,
508
+ });
509
+ const p = connect("prod", registry);
510
+
511
+ // Link job k1 (fires the still-pending resolution), then supersede it → k1 completes and persists
512
+ // its attribution BEFORE the element instance is known.
513
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
514
+ hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
515
+ assertEquals(store.get("k1")?.elementInstanceKey, undefined, "persisted before the element instance resolved");
516
+ assertEquals(correlation.resolve("k1"), undefined, "k1's live correlation was already released");
517
+
518
+ // The resolution finally lands — it backfills the durable row directly (the live context is gone).
519
+ release("ei-late");
520
+ await tick();
521
+ assertEquals(store.get("k1")?.elementInstanceKey, "ei-late", "the durable row is backfilled after the fact");
522
+ service.teardown();
523
+ });
524
+
525
+ test("#544 element-instance enrichment: an unresolved job (never parked) leaves the session un-keyed, not erroring", async () => {
526
+ const registry = new ConnectionRegistry();
527
+ const correlation = new CorrelationRegistry();
528
+ const db = memoryDb();
529
+ const store = new AgenticCorrelationStore(db);
530
+ const byConnection = new Map([["prod", "worker-A"]]);
531
+ const { service, hub } = mkCorrelatedService(registry, db, correlation, byConnection, {
532
+ correlationStore: store,
533
+ resolveElementInstance: () => Promise.resolve(undefined),
534
+ });
535
+ const p = connect("prod", registry);
536
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
537
+ await tick();
538
+ hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
539
+ const durable = store.get("k1");
540
+ assert(durable !== undefined, "the session is still attributed");
541
+ assertEquals(durable?.elementInstanceKey, undefined, "no element-instance key when the job was not resolvable");
542
+ service.teardown();
543
+ });
544
+
455
545
  /**
456
546
  * A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
457
547
  * `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are
@@ -35,6 +35,7 @@ import {
35
35
  import type { Logger } from "@nanobpm/urban";
36
36
  import { currentCorrelation, type JobContext, type JobCorrelation, jobKeyOfStream } from "../correlation.ts";
37
37
  import { AgenticCorrelationStore } from "../correlation-store.ts";
38
+ import type { ElementInstanceResolver } from "../element-instance.ts";
38
39
  import type { AgenticContext, AgenticFamily } from "../registry.ts";
39
40
  import { currentPresenceRegistry } from "./presence.family.ts";
40
41
 
@@ -105,6 +106,13 @@ interface StreamState {
105
106
  * instance's prior job stream.
106
107
  */
107
108
  instance?: string;
109
+ /**
110
+ * The engine element-instance key this `job:<jobKey>` stream's job occupies (#544), once the
111
+ * asynchronous link-time resolution ({@link RelayTranscriptService.#resolveElementInstance}) lands.
112
+ * Stashed on the stream so job completion can persist it even if the live correlation was already
113
+ * enriched-and-released, and so a resolution that returns after completion can still be recognised.
114
+ */
115
+ elementInstanceKey?: string;
108
116
  }
109
117
 
110
118
  /**
@@ -115,6 +123,12 @@ interface StreamState {
115
123
  export interface CorrelationLink {
116
124
  link(instance: string, jobKey: string, context?: JobContext): void;
117
125
  releaseJob(jobKey: string): void;
126
+ /**
127
+ * Enrich a still-linked job's context with the engine element-instance key it occupies (#544),
128
+ * resolved asynchronously after the link. Optional so the minimal double in tests need not implement
129
+ * it; the real {@link CorrelationRegistry} does. A no-op once the job is released.
130
+ */
131
+ attachElementInstance?(jobKey: string, elementInstanceKey: string): void;
118
132
  /**
119
133
  * The (still-live) engine context for a jobKey, when the write-side exposes it. Optional so the
120
134
  * minimal double in tests need not implement it; the real {@link CorrelationRegistry} does, and the
@@ -178,6 +192,15 @@ export interface RelayTranscriptServiceOptions {
178
192
  * no durable attribution). Injectable so a test can supply an in-memory store.
179
193
  */
180
194
  readonly correlationStore?: AgenticCorrelationStore;
195
+ /**
196
+ * Resolve the engine element-instance key a `job:<jobKey>` stream's job occupies (#544). Called
197
+ * fire-and-forget on the first `produce` (while the job's JOB park is still live), and its result
198
+ * enriches the live correlation context / durable attribution so a captured session is keyed on the
199
+ * element INSTANCE (unambiguous across a looping / retried job), not just the static element id.
200
+ * Advisory and READ-ONLY — never awaited in a frame handler, never gates a flow. {@link createRelayFamily}
201
+ * wires it to the channel's {@link AgenticContext.resolveElementInstance}; omitted → no enrichment.
202
+ */
203
+ readonly resolveElementInstance?: ElementInstanceResolver;
181
204
  /** "Now" as an ISO-8601 instant, injectable for deterministic completion timestamps. */
182
205
  readonly now?: () => string;
183
206
  }
@@ -226,6 +249,8 @@ export class RelayTranscriptService {
226
249
  readonly #attributionForInstance: (instance: string) => WorkerAttribution | undefined;
227
250
  /** The durable worker-attribution store, or undefined when unpersisted (#485). */
228
251
  readonly #correlationStore: AgenticCorrelationStore | undefined;
252
+ /** Resolve the engine element-instance key a job occupies (#544), or undefined when not wired. */
253
+ readonly #resolveElementInstance: ElementInstanceResolver | undefined;
229
254
  /** "Now" as an ISO-8601 instant (injectable for deterministic tests). */
230
255
  readonly #now: () => string;
231
256
 
@@ -235,6 +260,7 @@ export class RelayTranscriptService {
235
260
  this.#correlation = options.correlation ?? currentCorrelation;
236
261
  this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
237
262
  this.#attributionForInstance = options.attributionForInstance ?? (() => undefined);
263
+ this.#resolveElementInstance = options.resolveElementInstance;
238
264
  this.#now = options.now ?? (() => new Date().toISOString());
239
265
  // Persistence is advisory: a store that can't be constructed or whose schema can't be applied
240
266
  // (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
@@ -448,6 +474,10 @@ export class RelayTranscriptService {
448
474
  state.linked = true;
449
475
  state.instance = instance;
450
476
  this.#jobStreamByInstance.set(instance, stream);
477
+ // #544: resolve the element INSTANCE this job occupies while its JOB park is still live (the
478
+ // park is gone once the job completes, so this must fire at link time, not completion time).
479
+ // Advisory and asynchronous — fire-and-forget so it never blocks the synchronous frame handler.
480
+ this.#enrichElementInstance(stream, jobKey, state);
451
481
  } catch (err) {
452
482
  // Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
453
483
  // leave the stream UNLINKED so a later `produce` retries the link.
@@ -459,6 +489,51 @@ export class RelayTranscriptService {
459
489
  }
460
490
  }
461
491
 
492
+ /**
493
+ * #544: asynchronously resolve the engine element-instance key this job occupies and record it, from
494
+ * the first `produce` while the JOB park is still live. Fire-and-forget: it is invoked from the
495
+ * synchronous frame handler but never awaited, and every failure is swallowed (advisory). On success
496
+ * it enriches BOTH the live correlation context (so a still-running job's reads see it) AND stashes
497
+ * it on the stream state (so completion persists it); it ALSO backfills the durable row directly, to
498
+ * cover the race where resolution returns AFTER the job completed and released its live correlation.
499
+ */
500
+ #enrichElementInstance(stream: string, jobKey: string, state: StreamState): void {
501
+ const resolve = this.#resolveElementInstance;
502
+ if (resolve === undefined) return;
503
+ const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
504
+ // Self-contained advisory: a synchronous throw (a misbehaving resolver) is swallowed here rather
505
+ // than surfacing in `#link`'s catch as a misleading "link failed", and the async rejection path is
506
+ // handled by `.catch`. Either way this never throws into the synchronous frame handler.
507
+ let pending: Promise<string | undefined>;
508
+ try {
509
+ pending = resolve(jobKey, processInstanceKey);
510
+ } catch (err) {
511
+ this.#log.warn("agentic relay element-instance resolution failed — session left un-keyed", {
512
+ stream,
513
+ jobKey,
514
+ err: String(err),
515
+ });
516
+ return;
517
+ }
518
+ void pending
519
+ .then((elementInstanceKey) => {
520
+ if (elementInstanceKey === undefined || elementInstanceKey === "") return;
521
+ state.elementInstanceKey = elementInstanceKey;
522
+ // Enrich the live context if still linked (a no-op once released), and backfill the durable
523
+ // row if it was already persisted (a no-op before completion) — the two are complementary, so
524
+ // exactly one lands depending on whether resolution beat completion.
525
+ this.#correlation()?.attachElementInstance?.(jobKey, elementInstanceKey);
526
+ this.#correlationStore?.setElementInstanceKey(jobKey, elementInstanceKey);
527
+ })
528
+ .catch((err: unknown) => {
529
+ this.#log.warn("agentic relay element-instance resolution failed — session left un-keyed", {
530
+ stream,
531
+ jobKey,
532
+ err: String(err),
533
+ });
534
+ });
535
+ }
536
+
462
537
  /** H6 write-side (#149): release a `job:<jobKey>` stream's correlation on completion / disconnect. */
463
538
  #unlink(stream: string, state: StreamState): void {
464
539
  if (!state.linked) return;
@@ -505,6 +580,10 @@ export class RelayTranscriptService {
505
580
  try {
506
581
  const attribution = this.#attributionForInstance(instance) ?? {};
507
582
  const context = this.#correlation()?.resolve?.(jobKey);
583
+ // #544: prefer the live context's element-instance key; fall back to the stream state (the
584
+ // resolution may have landed after the context was released, or the context write-side may not
585
+ // carry it). Either source is the same resolved value.
586
+ const elementInstanceKey = context?.elementInstanceKey ?? state.elementInstanceKey;
508
587
  store.record({
509
588
  jobKey,
510
589
  stream,
@@ -516,6 +595,7 @@ export class RelayTranscriptService {
516
595
  ...(context?.bpmnProcessId !== undefined ? { bpmnProcessId: context.bpmnProcessId } : {}),
517
596
  ...(context?.elementId !== undefined ? { elementId: context.elementId } : {}),
518
597
  ...(context?.planKey !== undefined ? { planKey: context.planKey } : {}),
598
+ ...(elementInstanceKey !== undefined ? { elementInstanceKey } : {}),
519
599
  });
520
600
  } catch (err) {
521
601
  this.#log.warn("agentic correlation attribution persist failed — past session left unattributed", {
@@ -618,6 +698,10 @@ export function createRelayFamily(options: {
618
698
  // records instance only.
619
699
  attributionForInstance: (instance) => currentPresenceRegistry()?.attributionOf(instance),
620
700
  correlation: currentCorrelation,
701
+ // #544: the advisory element-instance resolver the composition root closed over the engine.
702
+ // Absent (engine-less host, or a test that mounts without it) → sessions are keyed on the
703
+ // static element id only, exactly as before this slice.
704
+ resolveElementInstance: ctx.resolveElementInstance,
621
705
  });
622
706
  setCurrentRelayTranscriptService(service);
623
707
 
@@ -23,11 +23,16 @@
23
23
  // - `db/migrations/024_agentic_transcript.sql` → H3 (#146)
24
24
  // - `db/migrations/025_agentic_blackboard.sql` → H4 (#147), only if it needs a schema change
25
25
  //
26
- // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
27
- // is untouched the agentic channel is the only new conversation; advisory semantics are preserved
28
- // (a family NEVER hard-locks or gates a BPMN sequence flow).
26
+ // Invariants (ADR 0056): app-tier only a family NEVER participates in or gates the Camunda-8 job
27
+ // protocol (worker⇄engine): it does not activate/complete jobs, publish messages, or gate a BPMN
28
+ // sequence flow; the agentic channel is the only new conversation, and its semantics are advisory. An
29
+ // ADVISORY, READ-ONLY query against the engine's read model (e.g. resolving the element instance a job
30
+ // occupies, #544) is permitted — it observes state to enrich a visibility record, never drives it — and
31
+ // is offered to families as the narrow {@link AgenticContext.resolveElementInstance} seam so a family
32
+ // depends on a capability, not the engine handle.
29
33
  import type { AgenticHub, ConnectionRegistry, WebSocketChannelTransport } from "@nanobpm/agentic/channel";
30
34
  import type { DataLayer, Logger } from "@nanobpm/urban";
35
+ import type { ElementInstanceResolver } from "./element-instance.ts";
31
36
 
32
37
  /**
33
38
  * The reusable handle the seam threads to every family module at mount time. A sibling family uses
@@ -43,6 +48,15 @@ export interface AgenticContext {
43
48
  readonly transport: WebSocketChannelTransport;
44
49
  /** The app's SQLite data layer — the same store the advisory blackboard uses (may be absent). */
45
50
  readonly data: DataLayer | undefined;
51
+ /**
52
+ * An ADVISORY, READ-ONLY element-instance resolver (#544), when the composition root supplies one.
53
+ * A family may call it to enrich a visibility record with the engine element-instance key a job
54
+ * occupies. It is a narrow function shape (not an engine handle) closed over the engine's element-
55
+ * instance wait-state read — so a family can query the engine's READ MODEL for advisory enrichment
56
+ * WITHOUT participating in or gating the Camunda-8 job protocol (the invariant above forbids the
57
+ * latter, not the former). Absent when no engine is wired (tests, engine-less hosts).
58
+ */
59
+ readonly resolveElementInstance?: ElementInstanceResolver;
46
60
  /** A structured logger for boot/shutdown lifecycle lines. */
47
61
  readonly log: Logger;
48
62
  }
@@ -9,6 +9,7 @@ import { DatabaseSync } from "node:sqlite";
9
9
  import type { SqliteDb, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import { AgenticCorrelationStore } from "./correlation-store.ts";
12
+ import { CorrelationRegistry } from "./correlation.ts";
12
13
  import { correlationFieldsFor, listTranscripts, readTranscriptFrom } from "./transcript-read.ts";
13
14
 
14
15
  /** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
@@ -107,6 +108,32 @@ test("durable fallback: a released (past) job is attributed from the durable sto
107
108
  assertEquals(fields.planKey, "acme/repo#42");
108
109
  });
109
110
 
111
+ test("#544: the durable element-instance key surfaces on the read projection and its filter", () => {
112
+ const durable = new AgenticCorrelationStore(memoryStore());
113
+ // Two iterations of the SAME static element (`agent`) — distinct element instances, distinct jobKeys.
114
+ durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-1", completedAt: early });
115
+ durable.record({ jobKey: "k2", stream: "job:k2", instance: "worker-A", elementId: "agent", elementInstanceKey: "ei-2", completedAt: late });
116
+
117
+ // The key surfaces on the correlation fields (durable fallback, live registry empty).
118
+ assertEquals(correlationFieldsFor("job:k1", undefined, durable).elementInstanceKey, "ei-1");
119
+
120
+ const store = fakeStore([meta("job:k1", early), meta("job:k2", late)]);
121
+ // The elementInstanceKey filter resolves a session to ONE occupancy, where the elementId cannot.
122
+ const out = listTranscripts(store, undefined, { elementInstanceKey: "ei-2" }, durable);
123
+ assertEquals(out.map((t) => t.stream), ["job:k2"], "only the ei-2 occupancy matches");
124
+ assertEquals(out[0].elementInstanceKey, "ei-2", "the projection carries the element-instance key");
125
+ });
126
+
127
+ test("#544: the live correlation's element-instance key takes precedence over the durable row on read", () => {
128
+ const registry = new CorrelationRegistry();
129
+ registry.link("worker-A", "k1", { elementId: "agent" });
130
+ registry.attachElementInstance("k1", "ei-live");
131
+ const durable = new AgenticCorrelationStore(memoryStore());
132
+ durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", elementInstanceKey: "ei-old", completedAt: mid });
133
+
134
+ assertEquals(correlationFieldsFor("job:k1", registry, durable).elementInstanceKey, "ei-live");
135
+ });
136
+
110
137
  test("listTranscripts: the instance filter returns only sessions the durable store attributes to that worker", () => {
111
138
  const store = fakeStore([meta("job:k1", early), meta("job:k2", mid), meta("job:k3", late)]);
112
139
  const durable = new AgenticCorrelationStore(memoryStore());
@@ -33,6 +33,8 @@ interface CorrelationFields {
33
33
  processInstanceKey?: string;
34
34
  bpmnProcessId?: string;
35
35
  elementId?: string;
36
+ /** The engine element-instance key the job's token occupied (#544) — per-occupancy, unlike elementId. */
37
+ elementInstanceKey?: string;
36
38
  planKey?: string;
37
39
  /** The worker instance that ran the job (durable — survives release / restart). */
38
40
  instance?: string;
@@ -63,6 +65,7 @@ export function correlationFieldsFor(
63
65
  if (context.processInstanceKey !== undefined) fields.processInstanceKey = context.processInstanceKey;
64
66
  if (context.bpmnProcessId !== undefined) fields.bpmnProcessId = context.bpmnProcessId;
65
67
  if (context.elementId !== undefined) fields.elementId = context.elementId;
68
+ if (context.elementInstanceKey !== undefined) fields.elementInstanceKey = context.elementInstanceKey;
66
69
  if (context.planKey !== undefined) fields.planKey = context.planKey;
67
70
  }
68
71
  // Durable fallback: fill any field the live registry did not supply (a released past session, or a
@@ -74,6 +77,9 @@ export function correlationFieldsFor(
74
77
  }
75
78
  if (fields.bpmnProcessId === undefined && row.bpmnProcessId !== undefined) fields.bpmnProcessId = row.bpmnProcessId;
76
79
  if (fields.elementId === undefined && row.elementId !== undefined) fields.elementId = row.elementId;
80
+ if (fields.elementInstanceKey === undefined && row.elementInstanceKey !== undefined) {
81
+ fields.elementInstanceKey = row.elementInstanceKey;
82
+ }
77
83
  if (fields.planKey === undefined && row.planKey !== undefined) fields.planKey = row.planKey;
78
84
  if (row.instance !== undefined) fields.instance = row.instance;
79
85
  if (row.identity !== undefined) fields.identity = row.identity;
@@ -106,6 +112,7 @@ export function toTranscript(
106
112
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
107
113
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
108
114
  if (fields.elementId !== undefined) out.elementId = fields.elementId;
115
+ if (fields.elementInstanceKey !== undefined) out.elementInstanceKey = fields.elementInstanceKey;
109
116
  if (fields.planKey !== undefined) out.planKey = fields.planKey;
110
117
  if (fields.instance !== undefined) out.instance = fields.instance;
111
118
  if (fields.identity !== undefined) out.identity = fields.identity;
@@ -117,6 +124,8 @@ export function toTranscript(
117
124
  export interface TranscriptFilter {
118
125
  readonly jobKey?: string;
119
126
  readonly processInstanceKey?: string;
127
+ /** The engine element-instance key (#544) — resolves a session to one occupancy of a looping activity. */
128
+ readonly elementInstanceKey?: string;
120
129
  readonly planKey?: string;
121
130
  /** The worker instance that ran the session (durable attribution) — powers the worker-history view. */
122
131
  readonly instance?: string;
@@ -145,6 +154,7 @@ export function listTranscripts(
145
154
  .filter((t) => {
146
155
  if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
147
156
  if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
157
+ if (filter.elementInstanceKey !== undefined && t.elementInstanceKey !== filter.elementInstanceKey) return false;
148
158
  if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
149
159
  if (filter.instance !== undefined && t.instance !== filter.instance) return false;
150
160
  const createdMs = Date.parse(t.createdAt);
@@ -225,6 +235,7 @@ export function readTranscriptFrom(
225
235
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
226
236
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
227
237
  if (fields.elementId !== undefined) out.elementId = fields.elementId;
238
+ if (fields.elementInstanceKey !== undefined) out.elementInstanceKey = fields.elementInstanceKey;
228
239
  if (fields.planKey !== undefined) out.planKey = fields.planKey;
229
240
  if (fields.instance !== undefined) out.instance = fields.instance;
230
241
  if (fields.identity !== undefined) out.identity = fields.identity;
@@ -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
+ }
@@ -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. Named constants so the worker's dispatch branch and the docs/preview can never drift
43
- * on the literal. */
44
- export const CONVERGE_TARGET = "converge";
45
- export const CONVERGE_MERGE_TARGET = "converge-merge";
46
-
47
- /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
48
- * predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
49
- * stub. */
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