@nanobpm/nano-workforce 0.139.4 → 0.141.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 +121 -0
- package/app/agentic/families/relay.family.ts +113 -1
- package/app/agentic/registry.ts +17 -3
- package/app/agentic/transcript-read.test.ts +122 -2
- package/app/agentic/transcript-read.ts +63 -21
- package/app/agentic/transcript-url.test.ts +35 -0
- package/app/agentic/transcript-url.ts +54 -0
- package/app/deliveryGraphCompiler.test.ts +27 -0
- package/app/deliveryGraphCompiler.ts +14 -0
- package/app/deliveryGraphDeploy.test.ts +79 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/feature.ts +6 -0
- package/db/migrations/086_agentic_correlation_element_instance.sql +21 -0
- package/main.ts +6 -0
- package/openapi.yaml +16 -0
- package/operations/getAgenticTranscript.ts +12 -4
- package/operations/listAgenticTranscripts.ts +1 -0
- package/package.json +2 -2
- package/resources/processes/feature.bpmn +2 -0
|
@@ -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 };
|
|
@@ -292,6 +300,37 @@ test("retention: a disconnected producer auto-completes its ephemeral stream on
|
|
|
292
300
|
service.teardown();
|
|
293
301
|
});
|
|
294
302
|
|
|
303
|
+
test("#486 live fallback: an uncompleted stream's ring is served pre-flush, then yields to the durable store", () => {
|
|
304
|
+
const registry = new ConnectionRegistry();
|
|
305
|
+
const hub = capturingHub();
|
|
306
|
+
const service = new RelayTranscriptService({
|
|
307
|
+
hub,
|
|
308
|
+
registry,
|
|
309
|
+
db: memoryDb(),
|
|
310
|
+
log: noopLog(),
|
|
311
|
+
now: () => "2026-03-04T05:06:07.000Z",
|
|
312
|
+
});
|
|
313
|
+
const p = connect("prod", registry);
|
|
314
|
+
for (let i = 0; i < 3; i++) hub.handler?.(produce(jobStream("Lk1"), 1, `r${i}`), p.conn);
|
|
315
|
+
|
|
316
|
+
// The job has completed and emitted its transcriptUrl, but this multiplexing worker is still live so
|
|
317
|
+
// no disconnect/supersede flushed the ring — the durable store still 404s (#486). The live fallback
|
|
318
|
+
// exposes the captured ring + its opened-at instant so the read path can serve it immediately.
|
|
319
|
+
assertEquals(service.transcriptOf(jobStream("Lk1")), undefined, "not yet flushed while the worker is live");
|
|
320
|
+
const live = service.liveFallback(jobStream("Lk1"));
|
|
321
|
+
assert(live !== undefined, "a live, unflushed stream has a serveable ring");
|
|
322
|
+
assertEquals(live.createdAt, "2026-03-04T05:06:07.000Z", "createdAt is the stream's opened-at instant");
|
|
323
|
+
assertEquals(live.ring.since(0).entries.length, 3, "the whole captured window is available");
|
|
324
|
+
assertEquals(live.ring.nextOffset, 3);
|
|
325
|
+
|
|
326
|
+
// Once the stream completes (flushed to durable), the durable store is the source of truth and the
|
|
327
|
+
// live fallback steps aside so a completed transcript is never double-sourced.
|
|
328
|
+
service.completeStream(jobStream("Lk1"));
|
|
329
|
+
assertEquals(service.transcriptOf(jobStream("Lk1"))?.status, "completed");
|
|
330
|
+
assertEquals(service.liveFallback(jobStream("Lk1")), undefined, "a completed stream no longer falls back to the ring");
|
|
331
|
+
service.teardown();
|
|
332
|
+
});
|
|
333
|
+
|
|
295
334
|
test("H6 correlation write-side: a produce on job:<k> links instance→[k]; stream completion releases it", () => {
|
|
296
335
|
const registry = new ConnectionRegistry();
|
|
297
336
|
const correlation = new CorrelationRegistry();
|
|
@@ -421,6 +460,88 @@ test("H6 durable attribution: completing/superseding a job persists the worker's
|
|
|
421
460
|
service.teardown();
|
|
422
461
|
});
|
|
423
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
|
+
|
|
424
545
|
/**
|
|
425
546
|
* A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
|
|
426
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
|
|
|
@@ -90,6 +91,14 @@ interface StreamState {
|
|
|
90
91
|
* instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
|
|
91
92
|
*/
|
|
92
93
|
linked: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* When this stream's in-memory state was first opened, ISO-8601 (stamped from the service clock).
|
|
96
|
+
* The durable transcript row carries its own `created_at` (stamped at flush/`open`), but a
|
|
97
|
+
* still-live ephemeral stream has no durable row yet (#486): its ring holds the captured bytes but
|
|
98
|
+
* the store 404s until a producer disconnect / supersede flushes it. This is the authoritative
|
|
99
|
+
* "when opened" for {@link RelayTranscriptService.liveFallback} to serve the pre-flush ring.
|
|
100
|
+
*/
|
|
101
|
+
createdAt: string;
|
|
93
102
|
/**
|
|
94
103
|
* The worker instance a `job:<jobKey>` stream was linked under (H6). Recorded so a stream's release
|
|
95
104
|
* (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
|
|
@@ -97,6 +106,13 @@ interface StreamState {
|
|
|
97
106
|
* instance's prior job stream.
|
|
98
107
|
*/
|
|
99
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;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
/**
|
|
@@ -107,6 +123,12 @@ interface StreamState {
|
|
|
107
123
|
export interface CorrelationLink {
|
|
108
124
|
link(instance: string, jobKey: string, context?: JobContext): void;
|
|
109
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;
|
|
110
132
|
/**
|
|
111
133
|
* The (still-live) engine context for a jobKey, when the write-side exposes it. Optional so the
|
|
112
134
|
* minimal double in tests need not implement it; the real {@link CorrelationRegistry} does, and the
|
|
@@ -170,6 +192,15 @@ export interface RelayTranscriptServiceOptions {
|
|
|
170
192
|
* no durable attribution). Injectable so a test can supply an in-memory store.
|
|
171
193
|
*/
|
|
172
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;
|
|
173
204
|
/** "Now" as an ISO-8601 instant, injectable for deterministic completion timestamps. */
|
|
174
205
|
readonly now?: () => string;
|
|
175
206
|
}
|
|
@@ -218,6 +249,8 @@ export class RelayTranscriptService {
|
|
|
218
249
|
readonly #attributionForInstance: (instance: string) => WorkerAttribution | undefined;
|
|
219
250
|
/** The durable worker-attribution store, or undefined when unpersisted (#485). */
|
|
220
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;
|
|
221
254
|
/** "Now" as an ISO-8601 instant (injectable for deterministic tests). */
|
|
222
255
|
readonly #now: () => string;
|
|
223
256
|
|
|
@@ -227,6 +260,7 @@ export class RelayTranscriptService {
|
|
|
227
260
|
this.#correlation = options.correlation ?? currentCorrelation;
|
|
228
261
|
this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
|
|
229
262
|
this.#attributionForInstance = options.attributionForInstance ?? (() => undefined);
|
|
263
|
+
this.#resolveElementInstance = options.resolveElementInstance;
|
|
230
264
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
231
265
|
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
232
266
|
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
@@ -440,6 +474,10 @@ export class RelayTranscriptService {
|
|
|
440
474
|
state.linked = true;
|
|
441
475
|
state.instance = instance;
|
|
442
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);
|
|
443
481
|
} catch (err) {
|
|
444
482
|
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
445
483
|
// leave the stream UNLINKED so a later `produce` retries the link.
|
|
@@ -451,6 +489,51 @@ export class RelayTranscriptService {
|
|
|
451
489
|
}
|
|
452
490
|
}
|
|
453
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
|
+
|
|
454
537
|
/** H6 write-side (#149): release a `job:<jobKey>` stream's correlation on completion / disconnect. */
|
|
455
538
|
#unlink(stream: string, state: StreamState): void {
|
|
456
539
|
if (!state.linked) return;
|
|
@@ -497,6 +580,10 @@ export class RelayTranscriptService {
|
|
|
497
580
|
try {
|
|
498
581
|
const attribution = this.#attributionForInstance(instance) ?? {};
|
|
499
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;
|
|
500
587
|
store.record({
|
|
501
588
|
jobKey,
|
|
502
589
|
stream,
|
|
@@ -508,6 +595,7 @@ export class RelayTranscriptService {
|
|
|
508
595
|
...(context?.bpmnProcessId !== undefined ? { bpmnProcessId: context.bpmnProcessId } : {}),
|
|
509
596
|
...(context?.elementId !== undefined ? { elementId: context.elementId } : {}),
|
|
510
597
|
...(context?.planKey !== undefined ? { planKey: context.planKey } : {}),
|
|
598
|
+
...(elementInstanceKey !== undefined ? { elementInstanceKey } : {}),
|
|
511
599
|
});
|
|
512
600
|
} catch (err) {
|
|
513
601
|
this.#log.warn("agentic correlation attribution persist failed — past session left unattributed", {
|
|
@@ -536,10 +624,30 @@ export class RelayTranscriptService {
|
|
|
536
624
|
}
|
|
537
625
|
}
|
|
538
626
|
|
|
627
|
+
/**
|
|
628
|
+
* The still-live relay ring for a stream, for the read path to serve BEFORE a durable flush (#486).
|
|
629
|
+
*
|
|
630
|
+
* A multiplexing worker relays every job over one long-lived connection, one job at a time, so an
|
|
631
|
+
* ephemeral job stream is only flushed to the durable store when the worker disconnects or a NEW
|
|
632
|
+
* job supersedes it — NOT when the job itself completes. In the window between "job completed
|
|
633
|
+
* (`transcriptUrl` emitted)" and that flush, {@link TranscriptStore.get} returns undefined and the
|
|
634
|
+
* transcript endpoint would 404 the freshly-emitted URL. This exposes the live ring (+ its opened-at
|
|
635
|
+
* instant) so {@link readTranscriptFrom} can serve the captured bytes directly, making the URL
|
|
636
|
+
* readable the moment it is emitted. Returns undefined when there is no live ring, or once the
|
|
637
|
+
* stream has been completed (the durable store is then the source of truth).
|
|
638
|
+
*/
|
|
639
|
+
liveFallback(stream: string): { ring: TranscriptRing; createdAt: string } | undefined {
|
|
640
|
+
const ring = this.relay.ring(stream);
|
|
641
|
+
if (ring === undefined) return undefined;
|
|
642
|
+
const state = this.#streams.get(stream);
|
|
643
|
+
if (state?.completed) return undefined;
|
|
644
|
+
return { ring, createdAt: state?.createdAt ?? this.#now() };
|
|
645
|
+
}
|
|
646
|
+
|
|
539
647
|
#stateFor(stream: string): StreamState {
|
|
540
648
|
let state = this.#streams.get(stream);
|
|
541
649
|
if (state === undefined) {
|
|
542
|
-
state = { lifecycle: "ephemeral", completed: false, linked: false };
|
|
650
|
+
state = { lifecycle: "ephemeral", completed: false, linked: false, createdAt: this.#now() };
|
|
543
651
|
this.#streams.set(stream, state);
|
|
544
652
|
}
|
|
545
653
|
return state;
|
|
@@ -590,6 +698,10 @@ export function createRelayFamily(options: {
|
|
|
590
698
|
// records instance only.
|
|
591
699
|
attributionForInstance: (instance) => currentPresenceRegistry()?.attributionOf(instance),
|
|
592
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,
|
|
593
705
|
});
|
|
594
706
|
setCurrentRelayTranscriptService(service);
|
|
595
707
|
|
package/app/agentic/registry.ts
CHANGED
|
@@ -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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// interaction with a missing/invalid createdAt — where a hand-built store lets us fix exact timestamps.
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { DatabaseSync } from "node:sqlite";
|
|
9
|
-
import type { SqliteDb, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
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 {
|
|
12
|
+
import { CorrelationRegistry } from "./correlation.ts";
|
|
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. */
|
|
15
16
|
function fakeStore(metas: TranscriptStream[]): TranscriptStore {
|
|
@@ -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());
|
|
@@ -122,3 +149,96 @@ test("listTranscripts: the instance filter returns only sessions the durable sto
|
|
|
122
149
|
);
|
|
123
150
|
assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
|
|
124
151
|
});
|
|
152
|
+
|
|
153
|
+
// --- #486: the still-live-ring read fallback that makes a freshly-emitted transcriptUrl readable ---
|
|
154
|
+
//
|
|
155
|
+
// A multiplexing worker relays every job over one long-lived connection and only flushes a job's ring
|
|
156
|
+
// to the durable store when it disconnects or a NEW job supersedes it — NOT when the job completes. In
|
|
157
|
+
// the window between "job completed (transcriptUrl emitted)" and that flush, the durable store has no
|
|
158
|
+
// row, so the transcript endpoint must serve the live ring or it would 404 the URL it just emitted.
|
|
159
|
+
|
|
160
|
+
/** A minimal live ring double satisfying {@link TranscriptRing}: the whole retained window from `from`. */
|
|
161
|
+
function fakeRing(entries: { offset: number; chunk: string }[]): TranscriptRing {
|
|
162
|
+
const nextOffset = entries.length === 0 ? 0 : entries[entries.length - 1].offset + 1;
|
|
163
|
+
return {
|
|
164
|
+
since: (from: number) => ({ entries: entries.filter((e) => e.offset >= from) }),
|
|
165
|
+
nextOffset,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A store double whose `get` returns a seeded row (or undefined), with the matching `since` window. */
|
|
170
|
+
function getStore(row: TranscriptStream | undefined, entries: { offset: number; chunk: string }[] = []): TranscriptStore {
|
|
171
|
+
return {
|
|
172
|
+
get: (_stream: string) => row,
|
|
173
|
+
since: (_stream: string, from: number) => ({
|
|
174
|
+
entries: entries.filter((e) => e.offset >= from),
|
|
175
|
+
gap: false,
|
|
176
|
+
nextOffset: row?.nextOffset ?? 0,
|
|
177
|
+
}),
|
|
178
|
+
} as unknown as TranscriptStore;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test("readTranscriptFrom: falls back to the live ring when the durable store has no row (#486)", () => {
|
|
182
|
+
const ring = fakeRing([
|
|
183
|
+
{ offset: 0, chunk: "hello " },
|
|
184
|
+
{ offset: 1, chunk: "world" },
|
|
185
|
+
]);
|
|
186
|
+
const out = readTranscriptFrom("job:live1", 0, getStore(undefined), undefined, undefined, {
|
|
187
|
+
ring,
|
|
188
|
+
createdAt: mid,
|
|
189
|
+
});
|
|
190
|
+
assert(out !== undefined, "a live-but-unflushed stream is readable, not a 404");
|
|
191
|
+
assertEquals(out.status, "open", "an unflushed live stream reads as open");
|
|
192
|
+
assertEquals(out.lifecycle, "ephemeral");
|
|
193
|
+
assertEquals(out.createdAt, mid);
|
|
194
|
+
assertEquals(out.nextOffset, 2);
|
|
195
|
+
assertEquals(out.chunkCount, 2);
|
|
196
|
+
assertEquals(
|
|
197
|
+
out.entries.map((e) => e.chunk).join(""),
|
|
198
|
+
"hello world",
|
|
199
|
+
"the captured bytes are served straight from the ring",
|
|
200
|
+
);
|
|
201
|
+
assertEquals(out.jobKey, "live1", "the jobKey is still decoded from the stream id");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("readTranscriptFrom: the live-ring fallback honours the resume-from offset", () => {
|
|
205
|
+
const ring = fakeRing([
|
|
206
|
+
{ offset: 0, chunk: "a" },
|
|
207
|
+
{ offset: 1, chunk: "b" },
|
|
208
|
+
{ offset: 2, chunk: "c" },
|
|
209
|
+
]);
|
|
210
|
+
const out = readTranscriptFrom("job:live2", 2, getStore(undefined), undefined, undefined, { ring, createdAt: mid });
|
|
211
|
+
assert(out !== undefined);
|
|
212
|
+
assertEquals(out.from, 2);
|
|
213
|
+
assertEquals(
|
|
214
|
+
out.entries.map((e) => e.chunk).join(""),
|
|
215
|
+
"c",
|
|
216
|
+
"only chunks at/after the requested offset are replayed",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("readTranscriptFrom: prefers the durable store once the ring has been flushed", () => {
|
|
221
|
+
const row: TranscriptStream = {
|
|
222
|
+
stream: "job:flushed",
|
|
223
|
+
lifecycle: "ephemeral",
|
|
224
|
+
status: "completed",
|
|
225
|
+
createdAt: early,
|
|
226
|
+
completedAt: late,
|
|
227
|
+
nextOffset: 1,
|
|
228
|
+
};
|
|
229
|
+
const store = getStore(row, [{ offset: 0, chunk: "durable" }]);
|
|
230
|
+
// A live ring is ALSO provided, but the flushed durable row wins (it is the source of truth once flushed).
|
|
231
|
+
const out = readTranscriptFrom("job:flushed", 0, store, undefined, undefined, {
|
|
232
|
+
ring: fakeRing([{ offset: 0, chunk: "stale-ring" }]),
|
|
233
|
+
createdAt: mid,
|
|
234
|
+
});
|
|
235
|
+
assert(out !== undefined);
|
|
236
|
+
assertEquals(out.status, "completed", "the flushed durable row is served, not the live ring");
|
|
237
|
+
assertEquals(out.completedAt, late);
|
|
238
|
+
assertEquals(out.entries.map((e) => e.chunk).join(""), "durable");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("readTranscriptFrom: returns undefined when neither the store nor a live ring has the stream", () => {
|
|
242
|
+
const out = readTranscriptFrom("job:gone", 0, getStore(undefined), undefined, undefined, undefined);
|
|
243
|
+
assertEquals(out, undefined);
|
|
244
|
+
});
|