@nanobpm/nano-workforce 0.140.0 → 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 +6 -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/db/migrations/086_agentic_correlation_element_instance.sql +21 -0
- package/main.ts +6 -0
- package/openapi.yaml +16 -0
- package/operations/listAgenticTranscripts.ts +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.141.0](https://github.com/nanobpm/nano-workforce/compare/v0.140.0...v0.141.0) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **agentic:** key agentic_correlation on elementInstanceKey ([#544](https://github.com/nanobpm/nano-workforce/issues/544)) ([#549](https://github.com/nanobpm/nano-workforce/issues/549)) ([3399597](https://github.com/nanobpm/nano-workforce/commit/33995973f4d207612ba62d6d9c421c40de1bcfba)), closes [nano-ide#473](https://github.com/nanobpm/nano-ide/issues/473)
|
|
6
|
+
|
|
1
7
|
## [0.140.0](https://github.com/nanobpm/nano-workforce/compare/v0.139.4...v0.140.0) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/app/agentic/channel.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
WebSocketChannelTransport,
|
|
23
23
|
} from "@nanobpm/agentic/channel";
|
|
24
24
|
import type { DataLayer, Logger } from "@nanobpm/urban";
|
|
25
|
+
import type { ElementInstanceResolver } from "./element-instance.ts";
|
|
25
26
|
import { loadAgenticFamilies } from "./loader.ts";
|
|
26
27
|
import { AgenticFamilyRegistry } from "./registry.ts";
|
|
27
28
|
|
|
@@ -83,6 +84,12 @@ export interface MountAgenticChannelOptions {
|
|
|
83
84
|
readonly secure?: boolean;
|
|
84
85
|
/** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
|
|
85
86
|
readonly data: DataLayer | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* An advisory, read-only element-instance resolver (#544), threaded to family modules via
|
|
89
|
+
* {@link AgenticContext.resolveElementInstance}. `main.ts` closes it over the shared engine's
|
|
90
|
+
* element-instance wait-state read; absent → families run without element-instance enrichment.
|
|
91
|
+
*/
|
|
92
|
+
readonly resolveElementInstance?: ElementInstanceResolver;
|
|
86
93
|
/** A structured logger for lifecycle lines. */
|
|
87
94
|
readonly log: Logger;
|
|
88
95
|
/**
|
|
@@ -193,7 +200,14 @@ export async function mountAgenticChannel(
|
|
|
193
200
|
let registry: AgenticFamilyRegistry | undefined;
|
|
194
201
|
try {
|
|
195
202
|
registry = await (opts.families ? opts.families() : discoverRegistry(log));
|
|
196
|
-
await registry.mountAll({
|
|
203
|
+
await registry.mountAll({
|
|
204
|
+
hub,
|
|
205
|
+
registry: hub.registry,
|
|
206
|
+
transport,
|
|
207
|
+
data,
|
|
208
|
+
resolveElementInstance: opts.resolveElementInstance,
|
|
209
|
+
log,
|
|
210
|
+
});
|
|
197
211
|
} catch (err) {
|
|
198
212
|
await registry?.teardownAll(log);
|
|
199
213
|
await hub.close();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Unit tests for the durable worker-attribution store (app/agentic/correlation-store.ts, #485).
|
|
2
|
-
// - drift guard:
|
|
2
|
+
// - drift guard: migrations 078 + 086 reproduce AGENTIC_CORRELATION_SCHEMA_SQL's effective schema;
|
|
3
3
|
// - record/get/byStream round-trips, including the optional (nullable) engine-context columns;
|
|
4
|
+
// - byElementInstance read axis (#544) and its per-occupancy uniqueness;
|
|
4
5
|
// - upsert semantics (re-recording a jobKey is last-write-wins).
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
@@ -24,20 +25,60 @@ function memoryDb(): SqliteDb {
|
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The effective schema of `agentic_correlation` as SQLite reports it: the ordered column list
|
|
30
|
+
* (name/type/nullability/default/pk) plus the set of indexes (name + normalised definition). This is
|
|
31
|
+
* derivation-over-duplication: comparing the *observed* schema rather than raw DDL text means the
|
|
32
|
+
* migration path and the canonical DDL are pinned to each other regardless of formatting, so the two
|
|
33
|
+
* cannot silently drift even though (post expand-and-contract) they are no longer byte-identical text.
|
|
34
|
+
*/
|
|
35
|
+
function effectiveSchema(db: DatabaseSync): string {
|
|
36
|
+
const columns = db
|
|
37
|
+
.prepare("PRAGMA table_info(agentic_correlation)")
|
|
38
|
+
.all()
|
|
39
|
+
.map((c) => {
|
|
40
|
+
const col = c as { name: string; type: string; notnull: number; dflt_value: unknown; pk: number };
|
|
41
|
+
return `${col.name}|${col.type}|${col.notnull}|${col.dflt_value ?? ""}|${col.pk}`;
|
|
42
|
+
})
|
|
33
43
|
.join("\n");
|
|
34
|
-
const
|
|
44
|
+
const indexes = db
|
|
45
|
+
.prepare(
|
|
46
|
+
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'agentic_correlation' ORDER BY name",
|
|
47
|
+
)
|
|
48
|
+
.all()
|
|
49
|
+
.map((i) => {
|
|
50
|
+
const idx = i as { name: string; sql: string | null };
|
|
51
|
+
return `${idx.name}|${(idx.sql ?? "").trim().replace(/\s+/g, " ")}`;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
return `COLUMNS\n${columns}\nINDEXES\n${indexes}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("drift guard: migrations 078 + 086 reproduce AGENTIC_CORRELATION_SCHEMA_SQL's effective schema", async () => {
|
|
58
|
+
const migrationsDir = join(HERE, "..", "..", "db", "migrations");
|
|
59
|
+
const base = await readFile(join(migrationsDir, "078_agentic_correlation.sql"), "utf8");
|
|
60
|
+
const expand = await readFile(join(migrationsDir, "086_agentic_correlation_element_instance.sql"), "utf8");
|
|
61
|
+
|
|
62
|
+
const migrated = new DatabaseSync(":memory:");
|
|
63
|
+
migrated.exec(base);
|
|
64
|
+
migrated.exec(expand);
|
|
65
|
+
|
|
66
|
+
const canonical = new DatabaseSync(":memory:");
|
|
67
|
+
canonical.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
|
|
68
|
+
|
|
35
69
|
assertEquals(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
70
|
+
effectiveSchema(migrated),
|
|
71
|
+
effectiveSchema(canonical),
|
|
72
|
+
"migrations 078 + 086 drifted from AGENTIC_CORRELATION_SCHEMA_SQL",
|
|
73
|
+
);
|
|
74
|
+
// The #544 column and its index are present in both paths.
|
|
75
|
+
assert(effectiveSchema(canonical).includes("element_instance_key"), "canonical DDL has element_instance_key");
|
|
76
|
+
assert(
|
|
77
|
+
effectiveSchema(canonical).includes("ix_agentic_correlation_element_instance"),
|
|
78
|
+
"canonical DDL indexes element_instance_key",
|
|
39
79
|
);
|
|
40
|
-
|
|
80
|
+
migrated.close();
|
|
81
|
+
canonical.close();
|
|
41
82
|
});
|
|
42
83
|
|
|
43
84
|
test("record + get round-trips full attribution, and byStream decodes the jobKey", () => {
|
|
@@ -67,6 +108,41 @@ test("record + get round-trips full attribution, and byStream decodes the jobKey
|
|
|
67
108
|
assertEquals(store.byStream(jobStream("job-1"))?.instance, "worker-A");
|
|
68
109
|
});
|
|
69
110
|
|
|
111
|
+
test("byElementInstance keys per-occupancy, distinguishing a looping/retried activity's iterations", () => {
|
|
112
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
113
|
+
// Two runs of the SAME static element (`agent`) in the same process instance — a loop / retry —
|
|
114
|
+
// occupy DISTINCT element instances. Keyed on element_id they'd be indistinguishable; keyed on the
|
|
115
|
+
// element-instance key each resolves to its own attribution (the whole point of #544).
|
|
116
|
+
store.record({
|
|
117
|
+
jobKey: "job-iter-1",
|
|
118
|
+
stream: jobStream("job-iter-1"),
|
|
119
|
+
instance: "worker-A",
|
|
120
|
+
processInstanceKey: "pi-9",
|
|
121
|
+
elementId: "agent",
|
|
122
|
+
elementInstanceKey: "ei-100",
|
|
123
|
+
completedAt: "2026-08-23T00:05:00.000Z",
|
|
124
|
+
});
|
|
125
|
+
store.record({
|
|
126
|
+
jobKey: "job-iter-2",
|
|
127
|
+
stream: jobStream("job-iter-2"),
|
|
128
|
+
instance: "worker-A",
|
|
129
|
+
processInstanceKey: "pi-9",
|
|
130
|
+
elementId: "agent",
|
|
131
|
+
elementInstanceKey: "ei-200",
|
|
132
|
+
completedAt: "2026-08-23T00:10:00.000Z",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assertEquals(store.get("job-iter-1")?.elementInstanceKey, "ei-100");
|
|
136
|
+
const first = store.byElementInstance("ei-100");
|
|
137
|
+
assertEquals(first.length, 1);
|
|
138
|
+
assertEquals(first[0].jobKey, "job-iter-1");
|
|
139
|
+
const second = store.byElementInstance("ei-200");
|
|
140
|
+
assertEquals(second.length, 1);
|
|
141
|
+
assertEquals(second[0].jobKey, "job-iter-2");
|
|
142
|
+
assertEquals(store.byElementInstance("ei-nope").length, 0);
|
|
143
|
+
assertEquals(store.byElementInstance("").length, 0);
|
|
144
|
+
});
|
|
145
|
+
|
|
70
146
|
test("optional context columns are omitted (not null) when unknown", () => {
|
|
71
147
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
72
148
|
store.record({
|
|
@@ -91,6 +167,23 @@ test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
|
|
|
91
167
|
assertEquals(got?.completedAt, "2026-08-23T02:10:00.000Z");
|
|
92
168
|
});
|
|
93
169
|
|
|
170
|
+
test("record preserves an existing element_instance_key when a later re-record omits it (monotonic)", () => {
|
|
171
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
172
|
+
const base = { jobKey: "job-4", stream: jobStream("job-4"), completedAt: "2026-08-23T02:00:00.000Z" };
|
|
173
|
+
// The durable backfill path (or a first record that carried the resolved key).
|
|
174
|
+
store.record({ ...base, instance: "worker-D", elementInstanceKey: "ei-777" });
|
|
175
|
+
// A later best-effort re-record that does NOT know the key must not wipe it back to NULL.
|
|
176
|
+
store.record({ ...base, instance: "worker-D", host: "later.local" });
|
|
177
|
+
const got = store.get("job-4");
|
|
178
|
+
assertEquals(got?.host, "later.local");
|
|
179
|
+
assertEquals(got?.elementInstanceKey, "ei-777");
|
|
180
|
+
// setElementInstanceKey backfill then a bare re-record likewise survives.
|
|
181
|
+
store.record({ jobKey: "job-5", stream: jobStream("job-5"), completedAt: "2026-08-23T03:00:00.000Z", instance: "worker-E" });
|
|
182
|
+
store.setElementInstanceKey("job-5", "ei-888");
|
|
183
|
+
store.record({ jobKey: "job-5", stream: jobStream("job-5"), completedAt: "2026-08-23T03:05:00.000Z", instance: "worker-E" });
|
|
184
|
+
assertEquals(store.get("job-5")?.elementInstanceKey, "ei-888");
|
|
185
|
+
});
|
|
186
|
+
|
|
94
187
|
test("get is undefined for an unknown jobKey and byStream undefined for a non-job stream", () => {
|
|
95
188
|
const store = new AgenticCorrelationStore(memoryDb());
|
|
96
189
|
assertEquals(store.get("nope"), undefined);
|
|
@@ -12,14 +12,21 @@
|
|
|
12
12
|
// by jobKey, so the transcript read path can recover a past session's worker + context after the
|
|
13
13
|
// worker has exited. Advisory / read-only (ADR 0056) — it NEVER gates a BPMN sequence flow.
|
|
14
14
|
//
|
|
15
|
-
// Single source of truth: {@link AGENTIC_CORRELATION_SCHEMA_SQL} is the canonical DDL
|
|
16
|
-
// idempotently on store construction (so unit tests over an in-memory DB have the table)
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// Single source of truth: {@link AGENTIC_CORRELATION_SCHEMA_SQL} is the canonical DDL, applied
|
|
16
|
+
// idempotently on store construction (so unit tests over an in-memory DB have the table). Its
|
|
17
|
+
// EFFECTIVE shape is reproduced by the forward-only migrations `db/migrations/078_agentic_correlation.sql`
|
|
18
|
+
// (base table) + `086_agentic_correlation_element_instance.sql` (#544 expand: the additive, nullable
|
|
19
|
+
// `element_instance_key`). A drift-guard test (`correlation-store.test.ts`) applies those migrations to
|
|
20
|
+
// one DB and the canonical DDL to another and asserts the two schemas are identical, so they can never
|
|
21
|
+
// diverge (the migrations, once merged, are immutable — the canonical DDL is what evolves).
|
|
19
22
|
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
20
23
|
import { jobKeyOfStream } from "./correlation.ts";
|
|
21
24
|
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The canonical DDL for the durable correlation table. The `078_*` + `086_*` migrations reproduce this
|
|
27
|
+
* effective shape (see the drift-guard test); `element_instance_key` is appended LAST to match the
|
|
28
|
+
* order SQLite gives a column added via `ALTER TABLE ... ADD COLUMN`.
|
|
29
|
+
*/
|
|
23
30
|
export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agentic_correlation (
|
|
24
31
|
job_key TEXT PRIMARY KEY,
|
|
25
32
|
stream TEXT NOT NULL,
|
|
@@ -31,11 +38,13 @@ export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agenti
|
|
|
31
38
|
element_id TEXT,
|
|
32
39
|
plan_key TEXT,
|
|
33
40
|
linked_at TEXT,
|
|
34
|
-
completed_at TEXT NOT NULL
|
|
41
|
+
completed_at TEXT NOT NULL,
|
|
42
|
+
element_instance_key TEXT
|
|
35
43
|
);
|
|
36
44
|
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
|
|
37
45
|
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
|
|
38
46
|
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_element_instance ON agentic_correlation (element_instance_key);
|
|
39
48
|
`;
|
|
40
49
|
|
|
41
50
|
/** One durable attribution row: which worker ran a job, plus its (best-effort) engine context. */
|
|
@@ -53,6 +62,12 @@ export interface DurableCorrelation {
|
|
|
53
62
|
readonly linkedAt?: string;
|
|
54
63
|
/** When the job completed (was flushed / released), ISO-8601. */
|
|
55
64
|
readonly completedAt: string;
|
|
65
|
+
/**
|
|
66
|
+
* The engine element-instance key the job's token occupies (#544). Unlike {@link elementId} (the
|
|
67
|
+
* STATIC BPMN id, ambiguous across a looping / retried job), this identifies the specific occupancy.
|
|
68
|
+
* Best-effort: undefined for pre-#544 rows and whenever resolution did not land.
|
|
69
|
+
*/
|
|
70
|
+
readonly elementInstanceKey?: string;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/** A row as stored (nullable columns come back as `null`). */
|
|
@@ -68,6 +83,7 @@ interface Row {
|
|
|
68
83
|
plan_key: string | null;
|
|
69
84
|
linked_at: string | null;
|
|
70
85
|
completed_at: string;
|
|
86
|
+
element_instance_key: string | null;
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
function fromRow(r: Row): DurableCorrelation {
|
|
@@ -86,6 +102,7 @@ function fromRow(r: Row): DurableCorrelation {
|
|
|
86
102
|
...(r.element_id !== null ? { elementId: r.element_id } : {}),
|
|
87
103
|
...(r.plan_key !== null ? { planKey: r.plan_key } : {}),
|
|
88
104
|
...(r.linked_at !== null ? { linkedAt: r.linked_at } : {}),
|
|
105
|
+
...(r.element_instance_key !== null ? { elementInstanceKey: r.element_instance_key } : {}),
|
|
89
106
|
};
|
|
90
107
|
}
|
|
91
108
|
|
|
@@ -104,12 +121,17 @@ export class AgenticCorrelationStore {
|
|
|
104
121
|
this.#db.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
|
|
105
122
|
}
|
|
106
123
|
|
|
107
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Upsert a completed job's attribution (last write wins on jobKey). One exception: the async
|
|
126
|
+
* `element_instance_key` (#544) is written MONOTONICALLY — a re-record that omits it (a common
|
|
127
|
+
* best-effort enrichment, or a value already set via {@link setElementInstanceKey}) preserves the
|
|
128
|
+
* stored key via COALESCE rather than clobbering it back to NULL.
|
|
129
|
+
*/
|
|
108
130
|
record(entry: DurableCorrelation): void {
|
|
109
131
|
this.#db.run(
|
|
110
132
|
`INSERT INTO agentic_correlation
|
|
111
|
-
(job_key, stream, instance, identity, host, process_instance_key, bpmn_process_id, element_id, plan_key, linked_at, completed_at)
|
|
112
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
133
|
+
(job_key, stream, instance, identity, host, process_instance_key, bpmn_process_id, element_id, plan_key, linked_at, completed_at, element_instance_key)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
113
135
|
ON CONFLICT(job_key) DO UPDATE SET
|
|
114
136
|
stream = excluded.stream,
|
|
115
137
|
instance = excluded.instance,
|
|
@@ -120,7 +142,8 @@ export class AgenticCorrelationStore {
|
|
|
120
142
|
element_id = excluded.element_id,
|
|
121
143
|
plan_key = excluded.plan_key,
|
|
122
144
|
linked_at = excluded.linked_at,
|
|
123
|
-
completed_at = excluded.completed_at
|
|
145
|
+
completed_at = excluded.completed_at,
|
|
146
|
+
element_instance_key = COALESCE(excluded.element_instance_key, agentic_correlation.element_instance_key)`,
|
|
124
147
|
[
|
|
125
148
|
entry.jobKey,
|
|
126
149
|
entry.stream,
|
|
@@ -133,10 +156,26 @@ export class AgenticCorrelationStore {
|
|
|
133
156
|
entry.planKey ?? null,
|
|
134
157
|
entry.linkedAt ?? null,
|
|
135
158
|
entry.completedAt,
|
|
159
|
+
entry.elementInstanceKey ?? null,
|
|
136
160
|
],
|
|
137
161
|
);
|
|
138
162
|
}
|
|
139
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Backfill the element-instance key onto an ALREADY-RECORDED row (#544), for the race where the
|
|
166
|
+
* asynchronous element-instance resolution returns AFTER the job completed and its attribution was
|
|
167
|
+
* persisted (the live correlation is gone, so it can no longer be enriched in memory). A no-op when
|
|
168
|
+
* no row exists yet (the resolution won the race — {@link record} will carry the key) or the key is
|
|
169
|
+
* empty. Advisory, like the rest of the store — never keyed on for control flow.
|
|
170
|
+
*/
|
|
171
|
+
setElementInstanceKey(jobKey: string, elementInstanceKey: string): void {
|
|
172
|
+
if (jobKey === "" || elementInstanceKey === "") return;
|
|
173
|
+
this.#db.run("UPDATE agentic_correlation SET element_instance_key = ? WHERE job_key = ?", [
|
|
174
|
+
elementInstanceKey,
|
|
175
|
+
jobKey,
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
140
179
|
/** The durable attribution for a jobKey, or undefined when none was recorded. */
|
|
141
180
|
get(jobKey: string): DurableCorrelation | undefined {
|
|
142
181
|
if (jobKey === "") return undefined;
|
|
@@ -159,4 +198,19 @@ export class AgenticCorrelationStore {
|
|
|
159
198
|
);
|
|
160
199
|
return rows.map(fromRow);
|
|
161
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every durable attribution recorded for an engine element-instance key (#544), newest completion
|
|
204
|
+
* first. This is the #544 read axis: unlike a static {@link DurableCorrelation.elementId}, an
|
|
205
|
+
* element-instance key names one specific occupancy, so a looping / retried activity's distinct
|
|
206
|
+
* iterations resolve to distinct rows here.
|
|
207
|
+
*/
|
|
208
|
+
byElementInstance(elementInstanceKey: string): DurableCorrelation[] {
|
|
209
|
+
if (elementInstanceKey === "") return [];
|
|
210
|
+
const rows = this.#db.all<Row>(
|
|
211
|
+
"SELECT * FROM agentic_correlation WHERE element_instance_key = ? ORDER BY completed_at DESC, job_key DESC",
|
|
212
|
+
[elementInstanceKey],
|
|
213
|
+
);
|
|
214
|
+
return rows.map(fromRow);
|
|
215
|
+
}
|
|
162
216
|
}
|
|
@@ -42,6 +42,38 @@ test("link records context and both projections; resolve carries the job: stream
|
|
|
42
42
|
assert.equal(reg.count(), 1);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("attachElementInstance enriches a linked job's context, preserving every other field (#544)", () => {
|
|
46
|
+
const reg = new CorrelationRegistry();
|
|
47
|
+
reg.link("wk-a", "6494", { processInstanceKey: "4612", elementId: "agent", planKey: "o/r#142" });
|
|
48
|
+
reg.attachElementInstance("6494", "ei-77");
|
|
49
|
+
const c = reg.resolve("6494");
|
|
50
|
+
assert.ok(c);
|
|
51
|
+
assert.equal(c.elementInstanceKey, "ei-77");
|
|
52
|
+
// Enrichment is additive — it must not clobber the fields the link established.
|
|
53
|
+
assert.equal(c.processInstanceKey, "4612");
|
|
54
|
+
assert.equal(c.elementId, "agent");
|
|
55
|
+
assert.equal(c.planKey, "o/r#142");
|
|
56
|
+
assert.equal(c.jobKey, "6494");
|
|
57
|
+
assert.equal(c.stream, "job:6494");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("attachElementInstance is a no-op for a released (or never-linked) job or an empty key (#544)", () => {
|
|
61
|
+
const reg = new CorrelationRegistry();
|
|
62
|
+
// Never linked.
|
|
63
|
+
reg.attachElementInstance("nope", "ei-1");
|
|
64
|
+
assert.equal(reg.resolve("nope"), undefined);
|
|
65
|
+
// Released before the resolution landed (the completion-race path).
|
|
66
|
+
reg.link("wk-a", "6494");
|
|
67
|
+
reg.releaseJob("6494");
|
|
68
|
+
reg.attachElementInstance("6494", "ei-1");
|
|
69
|
+
assert.equal(reg.resolve("6494"), undefined);
|
|
70
|
+
// Empty inputs are ignored, and never materialise a context.
|
|
71
|
+
reg.link("wk-b", "7000");
|
|
72
|
+
reg.attachElementInstance("7000", "");
|
|
73
|
+
assert.equal(reg.resolve("7000")?.elementInstanceKey, undefined);
|
|
74
|
+
reg.attachElementInstance("", "ei-1");
|
|
75
|
+
});
|
|
76
|
+
|
|
45
77
|
test("link ignores empty instance or jobKey", () => {
|
|
46
78
|
const reg = new CorrelationRegistry();
|
|
47
79
|
reg.link("", "6494");
|
|
@@ -70,6 +102,20 @@ test("re-linking a jobKey to a new instance MOVES it (drops the stale reverse ed
|
|
|
70
102
|
assert.equal(reg.count(), 1);
|
|
71
103
|
});
|
|
72
104
|
|
|
105
|
+
test("re-linking a jobKey preserves an already-attached elementInstanceKey (no clobber)", () => {
|
|
106
|
+
const reg = new CorrelationRegistry();
|
|
107
|
+
reg.link("wk-a", "6494");
|
|
108
|
+
reg.attachElementInstance("6494", "ei-42");
|
|
109
|
+
// A subsequent bare re-link (worker reconnect mid-job) must not wipe the async-resolved key.
|
|
110
|
+
reg.link("wk-a", "6494");
|
|
111
|
+
assert.equal(reg.resolve("6494")?.elementInstanceKey, "ei-42");
|
|
112
|
+
// A move to a new worker connection likewise keeps the element-instance the job occupies.
|
|
113
|
+
reg.link("wk-b", "6494", { planKey: "o/r#7" });
|
|
114
|
+
assert.equal(reg.resolve("6494")?.elementInstanceKey, "ei-42");
|
|
115
|
+
assert.equal(reg.resolve("6494")?.planKey, "o/r#7");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
|
|
73
119
|
test("releaseJob removes one job from both projections", () => {
|
|
74
120
|
const reg = new CorrelationRegistry();
|
|
75
121
|
reg.link("wk-a", "6494");
|
|
@@ -55,6 +55,13 @@ export interface JobCorrelation {
|
|
|
55
55
|
readonly bpmnProcessId?: string;
|
|
56
56
|
/** The BPMN element id (activity/task) the job is for, if known. */
|
|
57
57
|
readonly elementId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The engine element-instance key the job's token occupies (#544), if resolved. Unlike
|
|
60
|
+
* {@link elementId} (the static id, ambiguous across a looping / retried activity) this names the
|
|
61
|
+
* specific occupancy. Best-effort: it is enriched asynchronously via {@link CorrelationRegistry.attachElementInstance}
|
|
62
|
+
* after the link, so it may be absent for a just-linked job and stays absent if resolution never lands.
|
|
63
|
+
*/
|
|
64
|
+
readonly elementInstanceKey?: string;
|
|
58
65
|
/** The plan / epic key this job is part of (e.g. `owner/repo#142`), if known. */
|
|
59
66
|
readonly planKey?: string;
|
|
60
67
|
/** The relay stream id the job's terminal is relayed on (`job:<jobKey>`). */
|
|
@@ -86,8 +93,12 @@ export class CorrelationRegistry {
|
|
|
86
93
|
|
|
87
94
|
/**
|
|
88
95
|
* Link a worker instance to a job it is now processing, recording the job's engine context. A
|
|
89
|
-
* re-link of the same jobKey to a different instance moves it (dropping the old reverse edge)
|
|
90
|
-
*
|
|
96
|
+
* re-link of the same jobKey to a different instance moves it (dropping the old reverse edge). The
|
|
97
|
+
* context is MERGED over any existing attribution, not replaced: explicitly provided fields win
|
|
98
|
+
* (last write wins per field), but fields omitted from `context` are preserved — so a bare re-link
|
|
99
|
+
* cannot clear or clobber context another path already attached (e.g. an async-resolved
|
|
100
|
+
* `elementInstanceKey`). `jobKey`/`stream` are always re-derived canonically. Both args must be
|
|
101
|
+
* non-empty.
|
|
91
102
|
*/
|
|
92
103
|
link(instance: string, jobKey: string, context: JobContext = {}): void {
|
|
93
104
|
if (instance === "" || jobKey === "") return;
|
|
@@ -100,7 +111,32 @@ export class CorrelationRegistry {
|
|
|
100
111
|
const jobs = this.#jobsOf.get(instance) ?? new Set<string>();
|
|
101
112
|
jobs.add(jobKey);
|
|
102
113
|
this.#jobsOf.set(instance, jobs);
|
|
103
|
-
|
|
114
|
+
// Merge over any existing attribution rather than clobbering it: a jobKey is engine-unique, so a
|
|
115
|
+
// re-link (e.g. a worker reconnecting mid-job, or a richer orchestrator context arriving later)
|
|
116
|
+
// must not drop context another path already attached — notably the #544 `elementInstanceKey`,
|
|
117
|
+
// which is resolved asynchronously and could otherwise be wiped by a subsequent bare `link`.
|
|
118
|
+
// Explicitly provided fields still win; `jobKey`/`stream` are always re-derived canonically.
|
|
119
|
+
const existing = this.#context.get(jobKey);
|
|
120
|
+
this.#context.set(jobKey, {
|
|
121
|
+
...existing,
|
|
122
|
+
jobKey,
|
|
123
|
+
stream: jobStream(jobKey),
|
|
124
|
+
...stripUndefined(context),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Enrich a still-linked job's context with the engine element-instance key it occupies (#544),
|
|
130
|
+
* resolved asynchronously after the link (the element-instance read is an engine round-trip the
|
|
131
|
+
* synchronous link path cannot await). Best-effort and idempotent: a no-op if the job is no longer
|
|
132
|
+
* linked (it completed and released before resolution returned — the durable backfill covers that
|
|
133
|
+
* case instead) or if the key is empty. Preserves every other context field.
|
|
134
|
+
*/
|
|
135
|
+
attachElementInstance(jobKey: string, elementInstanceKey: string): void {
|
|
136
|
+
if (jobKey === "" || elementInstanceKey === "") return;
|
|
137
|
+
const existing = this.#context.get(jobKey);
|
|
138
|
+
if (existing === undefined) return;
|
|
139
|
+
this.#context.set(jobKey, { ...existing, elementInstanceKey });
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
/** Release one job (it finished / moved on). No-op if it was never linked. */
|
|
@@ -170,11 +206,12 @@ export class CorrelationRegistry {
|
|
|
170
206
|
|
|
171
207
|
/** Drop `undefined`-valued keys so the stored context never materializes an explicit `{ key: undefined }` hole. */
|
|
172
208
|
function stripUndefined(context: JobContext): JobContext {
|
|
173
|
-
const { processInstanceKey, bpmnProcessId, elementId, planKey } = context;
|
|
209
|
+
const { processInstanceKey, bpmnProcessId, elementId, elementInstanceKey, planKey } = context;
|
|
174
210
|
return {
|
|
175
211
|
...(processInstanceKey !== undefined ? { processInstanceKey } : {}),
|
|
176
212
|
...(bpmnProcessId !== undefined ? { bpmnProcessId } : {}),
|
|
177
213
|
...(elementId !== undefined ? { elementId } : {}),
|
|
214
|
+
...(elementInstanceKey !== undefined ? { elementInstanceKey } : {}),
|
|
178
215
|
...(planKey !== undefined ? { planKey } : {}),
|
|
179
216
|
};
|
|
180
217
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Acceptance test for the #544 element-instance resolver (app/agentic/element-instance.ts).
|
|
2
|
+
//
|
|
3
|
+
// The load-bearing case is the one the issue names: correlation must resolve the CORRECT element
|
|
4
|
+
// instance across a RETRIED / LOOPING job — where the same static BPMN `elementId` occupies several
|
|
5
|
+
// distinct element instances, each with its own jobKey. Keying on `elementId` would be ambiguous;
|
|
6
|
+
// keying on `jobKey` (engine-unique per activation) is not. These tests drive the resolver against a
|
|
7
|
+
// fake wait-state reader and assert each jobKey resolves to its own occupancy's `elementInstanceKey`.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import type { ElementInstanceWaitState, ElementInstanceWaitStateFilter } from "@nanobpm/urban";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
|
+
import {
|
|
12
|
+
type ElementInstanceWaitStateReader,
|
|
13
|
+
resolveElementInstanceKey,
|
|
14
|
+
} from "./element-instance.ts";
|
|
15
|
+
|
|
16
|
+
/** A fake engine wait-state read model: returns the configured parks, honouring the filter. */
|
|
17
|
+
function fakeReader(
|
|
18
|
+
parks: readonly ElementInstanceWaitState[],
|
|
19
|
+
): ElementInstanceWaitStateReader & { calls: ElementInstanceWaitStateFilter[] } {
|
|
20
|
+
const calls: ElementInstanceWaitStateFilter[] = [];
|
|
21
|
+
return {
|
|
22
|
+
calls,
|
|
23
|
+
searchElementInstanceWaitStates: (filter: ElementInstanceWaitStateFilter = {}) => {
|
|
24
|
+
calls.push(filter);
|
|
25
|
+
const matched = parks.filter((p) => {
|
|
26
|
+
if (filter.waitStateType !== undefined && p.waitStateType !== filter.waitStateType) return false;
|
|
27
|
+
if (filter.processInstanceKey !== undefined && p.processInstanceKey !== filter.processInstanceKey) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (filter.elementId !== undefined && p.elementId !== filter.elementId) return false;
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
return Promise.resolve(matched);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build a JOB wait-state park (a service task awaiting a worker). */
|
|
39
|
+
function jobPark(over: {
|
|
40
|
+
elementInstanceKey: string;
|
|
41
|
+
jobKey: string;
|
|
42
|
+
processInstanceKey?: string;
|
|
43
|
+
elementId?: string;
|
|
44
|
+
}): ElementInstanceWaitState {
|
|
45
|
+
return {
|
|
46
|
+
elementInstanceKey: over.elementInstanceKey,
|
|
47
|
+
processInstanceKey: over.processInstanceKey ?? "pi-1",
|
|
48
|
+
elementId: over.elementId ?? "agent",
|
|
49
|
+
waitStateType: "JOB",
|
|
50
|
+
jobType: "senior:feature",
|
|
51
|
+
jobKey: over.jobKey,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("resolves the element instance whose JOB park carries the matching jobKey", async () => {
|
|
56
|
+
const reader = fakeReader([jobPark({ elementInstanceKey: "ei-100", jobKey: "job-abc" })]);
|
|
57
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-abc"), "ei-100");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("looping/retried job: same elementId across iterations resolves each jobKey to its own instance", async () => {
|
|
61
|
+
// Three live parks for the SAME static element `agent` in the SAME process instance — the shape a
|
|
62
|
+
// retried / looping activity produces: distinct element instances, distinct jobKeys, one elementId.
|
|
63
|
+
const reader = fakeReader([
|
|
64
|
+
jobPark({ elementInstanceKey: "ei-1", jobKey: "job-iter-1", elementId: "agent" }),
|
|
65
|
+
jobPark({ elementInstanceKey: "ei-2", jobKey: "job-iter-2", elementId: "agent" }),
|
|
66
|
+
jobPark({ elementInstanceKey: "ei-3", jobKey: "job-iter-3", elementId: "agent" }),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Keying on elementId would be ambiguous (all three share `agent`); keying on jobKey is exact.
|
|
70
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-iter-1"), "ei-1");
|
|
71
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-iter-2"), "ei-2");
|
|
72
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-iter-3"), "ei-3");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("scopes the engine search to JOB parks, and to the process instance when known", async () => {
|
|
76
|
+
const reader = fakeReader([
|
|
77
|
+
jobPark({ elementInstanceKey: "ei-a", jobKey: "job-a", processInstanceKey: "pi-1" }),
|
|
78
|
+
jobPark({ elementInstanceKey: "ei-b", jobKey: "job-b", processInstanceKey: "pi-2" }),
|
|
79
|
+
]);
|
|
80
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-b", { processInstanceKey: "pi-2" }), "ei-b");
|
|
81
|
+
assertEquals(reader.calls.length, 1);
|
|
82
|
+
assertEquals(reader.calls[0].waitStateType, "JOB");
|
|
83
|
+
assertEquals(reader.calls[0].processInstanceKey, "pi-2");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("unscoped resolution still matches on jobKey across process instances", async () => {
|
|
87
|
+
const reader = fakeReader([
|
|
88
|
+
jobPark({ elementInstanceKey: "ei-a", jobKey: "job-a", processInstanceKey: "pi-1" }),
|
|
89
|
+
jobPark({ elementInstanceKey: "ei-b", jobKey: "job-b", processInstanceKey: "pi-2" }),
|
|
90
|
+
]);
|
|
91
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-b"), "ei-b");
|
|
92
|
+
assertEquals(reader.calls[0].processInstanceKey, undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns undefined when the job is not parked (completed / released) or jobKey is empty", async () => {
|
|
96
|
+
const reader = fakeReader([jobPark({ elementInstanceKey: "ei-1", jobKey: "job-live" })]);
|
|
97
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-gone"), undefined);
|
|
98
|
+
assertEquals(await resolveElementInstanceKey(reader, ""), undefined);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("ignores a non-JOB park that happens to share the process instance", async () => {
|
|
102
|
+
const parks: ElementInstanceWaitState[] = [
|
|
103
|
+
{ elementInstanceKey: "ei-msg", processInstanceKey: "pi-1", elementId: "wait", waitStateType: "MESSAGE", messageName: "m" },
|
|
104
|
+
jobPark({ elementInstanceKey: "ei-job", jobKey: "job-x", processInstanceKey: "pi-1" }),
|
|
105
|
+
];
|
|
106
|
+
// Even if the engine ignored the JOB filter, only the JOB park's jobKey can match.
|
|
107
|
+
const reader: ElementInstanceWaitStateReader = {
|
|
108
|
+
searchElementInstanceWaitStates: () => Promise.resolve(parks),
|
|
109
|
+
};
|
|
110
|
+
assertEquals(await resolveElementInstanceKey(reader, "job-x"), "ei-job");
|
|
111
|
+
});
|
|
@@ -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
|
|
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
|
}
|
|
@@ -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,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);
|
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) {
|
package/openapi.yaml
CHANGED
|
@@ -643,6 +643,11 @@ components:
|
|
|
643
643
|
elementId:
|
|
644
644
|
type: string
|
|
645
645
|
description: The BPMN element id (activity/task) the job was for, when still known (advisory).
|
|
646
|
+
elementInstanceKey:
|
|
647
|
+
type: string
|
|
648
|
+
description: The engine element-instance key the job's token occupied (#544) — the per-occupancy
|
|
649
|
+
handle, unambiguous across a looping / retried activity where many instances share one elementId.
|
|
650
|
+
Resolved from the job's element-instance wait-state; advisory, present when resolution landed.
|
|
646
651
|
planKey:
|
|
647
652
|
type: string
|
|
648
653
|
description: The plan / epic key this job was part of (e.g. owner/repo#142), when still known (advisory).
|
|
@@ -749,6 +754,10 @@ components:
|
|
|
749
754
|
elementId:
|
|
750
755
|
type: string
|
|
751
756
|
description: The BPMN element id, when still known (advisory).
|
|
757
|
+
elementInstanceKey:
|
|
758
|
+
type: string
|
|
759
|
+
description: The engine element-instance key the job's token occupied (#544) — per-occupancy,
|
|
760
|
+
unambiguous across a looping / retried activity; advisory, present when resolution landed.
|
|
752
761
|
planKey:
|
|
753
762
|
type: string
|
|
754
763
|
description: The plan / epic key, when still known (advisory).
|
|
@@ -2802,6 +2811,13 @@ paths:
|
|
|
2802
2811
|
schema:
|
|
2803
2812
|
type: string
|
|
2804
2813
|
description: Return only transcripts whose (still-known) correlation names this process instance.
|
|
2814
|
+
- name: elementInstanceKey
|
|
2815
|
+
in: query
|
|
2816
|
+
required: false
|
|
2817
|
+
schema:
|
|
2818
|
+
type: string
|
|
2819
|
+
description: Return only transcripts whose correlation names this engine element-instance key (#544) —
|
|
2820
|
+
resolves a session to one occupancy of a looping / retried activity, unlike the static elementId.
|
|
2805
2821
|
- name: planKey
|
|
2806
2822
|
in: query
|
|
2807
2823
|
required: false
|
|
@@ -46,6 +46,7 @@ export default defineOperation("listAgenticTranscripts", async ({ query, req },
|
|
|
46
46
|
const filter: TranscriptFilter = {
|
|
47
47
|
...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
|
|
48
48
|
...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
|
|
49
|
+
...(query.elementInstanceKey !== undefined ? { elementInstanceKey: query.elementInstanceKey } : {}),
|
|
49
50
|
...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
|
|
50
51
|
...(query.instance !== undefined ? { instance: query.instance } : {}),
|
|
51
52
|
...(query.since !== undefined ? { since: query.since } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.141.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.4.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.83.0",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|