@nanobpm/nano-workforce 0.127.0 → 0.128.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/.github/workflows/release.yml +29 -6
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +7 -0
- package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
- package/app/agentic/cockpit/cockpit-route.ts +17 -0
- package/app/agentic/cockpit/index.ts +16 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
- package/app/agentic/cockpit/supply-boot.test.ts +2 -2
- package/app/agentic/cockpit/supply-boot.ts +76 -10
- package/app/agentic/cockpit/supply-render.test.ts +13 -4
- package/app/agentic/cockpit/supply-render.ts +14 -2
- package/app/agentic/cockpit/transcript-render.ts +6 -2
- package/app/agentic/cockpit/transcript-view.ts +9 -0
- package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
- package/app/agentic/cockpit/worker-detail-render.ts +88 -0
- package/app/agentic/cockpit/worker-detail-view.ts +43 -0
- package/app/agentic/correlation-store.test.ts +99 -0
- package/app/agentic/correlation-store.ts +162 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +14 -0
- package/app/agentic/families/relay.family.test.ts +72 -0
- package/app/agentic/families/relay.family.ts +130 -1
- package/app/agentic/transcript-read.test.ts +55 -3
- package/app/agentic/transcript-read.ts +49 -9
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/openapi.yaml +26 -0
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
|
|
2
|
+
import type { WorkerDetailView } from "./worker-detail-view.ts";
|
|
3
|
+
|
|
4
|
+
export interface RenderWorkerDetailOptions {
|
|
5
|
+
readonly onBack?: () => void;
|
|
6
|
+
readonly onDrill?: (stream: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WorkerDetailDom {
|
|
10
|
+
readonly root: ElementLike;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
|
|
14
|
+
const node = doc.createElement(tag);
|
|
15
|
+
if (className !== undefined) node.className = className;
|
|
16
|
+
if (text !== undefined) node.textContent = text;
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function backButton(doc: DocumentLike, onBack: (() => void) | undefined): ElementLike {
|
|
21
|
+
const button = el(doc, "button", "cockpit-worker-detail-back", "← Workers");
|
|
22
|
+
button.setAttribute("type", "button");
|
|
23
|
+
if (onBack !== undefined) button.addEventListener("click", onBack);
|
|
24
|
+
return button;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderWorkerDetail(
|
|
28
|
+
host: ElementLike,
|
|
29
|
+
doc: DocumentLike,
|
|
30
|
+
view: WorkerDetailView,
|
|
31
|
+
options: RenderWorkerDetailOptions = {},
|
|
32
|
+
): WorkerDetailDom {
|
|
33
|
+
host.replaceChildren();
|
|
34
|
+
const root = el(doc, "section", "cockpit-worker-detail");
|
|
35
|
+
root.appendChild(backButton(doc, options.onBack));
|
|
36
|
+
|
|
37
|
+
if (view.kind === "missing") {
|
|
38
|
+
root.setAttribute("data-worker-missing", view.instance);
|
|
39
|
+
root.appendChild(el(doc, "h1", "cockpit-title", `Worker ${view.instance}`));
|
|
40
|
+
root.appendChild(el(doc, "div", "cockpit-worker-detail-empty", `Worker ${view.instance} is not in the current supply report.`));
|
|
41
|
+
host.appendChild(root);
|
|
42
|
+
return { root };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const worker = view.worker;
|
|
46
|
+
root.setAttribute("data-worker-detail", worker.instance);
|
|
47
|
+
root.setAttribute("data-liveness", worker.liveness);
|
|
48
|
+
|
|
49
|
+
const header = el(doc, "header", "cockpit-worker-detail-header");
|
|
50
|
+
const title = el(doc, "h1", "cockpit-title", worker.instance);
|
|
51
|
+
header.appendChild(title);
|
|
52
|
+
const meta = el(doc, "dl", "cockpit-worker-detail-meta");
|
|
53
|
+
for (const [klass, label, value] of [
|
|
54
|
+
["identity", "identity", worker.identity],
|
|
55
|
+
["host", "host", worker.host],
|
|
56
|
+
["family", "family", worker.family],
|
|
57
|
+
["liveness", "liveness", worker.liveness],
|
|
58
|
+
] as const) {
|
|
59
|
+
const item = el(doc, "div", "cockpit-worker-detail-meta-item");
|
|
60
|
+
item.setAttribute("data-field", klass);
|
|
61
|
+
item.appendChild(el(doc, "dt", undefined, label));
|
|
62
|
+
const dd = el(doc, "dd", `cockpit-worker-detail-${klass}`, value);
|
|
63
|
+
if (klass === "liveness") dd.setAttribute("data-liveness", worker.liveness);
|
|
64
|
+
item.appendChild(dd);
|
|
65
|
+
meta.appendChild(item);
|
|
66
|
+
}
|
|
67
|
+
header.appendChild(meta);
|
|
68
|
+
root.appendChild(header);
|
|
69
|
+
|
|
70
|
+
const current = el(doc, "section", "cockpit-worker-current");
|
|
71
|
+
current.appendChild(el(doc, "h2", "cockpit-panel-title", "Current job"));
|
|
72
|
+
if (view.currentJob === undefined) {
|
|
73
|
+
current.appendChild(el(doc, "div", "cockpit-worker-current-empty", "No active job."));
|
|
74
|
+
} else {
|
|
75
|
+
const currentJob = view.currentJob;
|
|
76
|
+
const button = el(doc, "button", "cockpit-worker-current-job", currentJob.label);
|
|
77
|
+
button.setAttribute("type", "button");
|
|
78
|
+
button.setAttribute("data-job-key", currentJob.jobKey);
|
|
79
|
+
button.setAttribute("data-stream", currentJob.stream);
|
|
80
|
+
const onDrill = options.onDrill;
|
|
81
|
+
if (onDrill !== undefined) button.addEventListener("click", () => onDrill(currentJob.stream));
|
|
82
|
+
current.appendChild(button);
|
|
83
|
+
}
|
|
84
|
+
root.appendChild(current);
|
|
85
|
+
|
|
86
|
+
host.appendChild(root);
|
|
87
|
+
return { root };
|
|
88
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { JobCorrelationView, SupplyView, SupplyWorkerView } from "./supply-view.ts";
|
|
2
|
+
|
|
3
|
+
export interface WorkerCurrentJobView {
|
|
4
|
+
readonly jobKey: string;
|
|
5
|
+
readonly stream: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FoundWorkerDetailView {
|
|
10
|
+
readonly kind: "found";
|
|
11
|
+
readonly worker: SupplyWorkerView;
|
|
12
|
+
readonly currentJob?: WorkerCurrentJobView;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MissingWorkerDetailView {
|
|
16
|
+
readonly kind: "missing";
|
|
17
|
+
readonly instance: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type WorkerDetailView = FoundWorkerDetailView | MissingWorkerDetailView;
|
|
21
|
+
|
|
22
|
+
function currentJob(worker: SupplyWorkerView): WorkerCurrentJobView | undefined {
|
|
23
|
+
const correlation: JobCorrelationView | undefined = worker.correlations[0];
|
|
24
|
+
if (correlation !== undefined) {
|
|
25
|
+
return { jobKey: correlation.jobKey, stream: correlation.stream, label: correlation.label };
|
|
26
|
+
}
|
|
27
|
+
const jobKey = worker.jobKeys[0];
|
|
28
|
+
if (jobKey === undefined) return undefined;
|
|
29
|
+
return { jobKey, stream: worker.stream, label: `job ${jobKey}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function workerDetailView(view: SupplyView, instance: string): WorkerDetailView {
|
|
33
|
+
const worker = view.workers.find((w) => w.instance === instance);
|
|
34
|
+
if (worker === undefined) return { kind: "missing", instance };
|
|
35
|
+
return {
|
|
36
|
+
kind: "found",
|
|
37
|
+
worker,
|
|
38
|
+
...(() => {
|
|
39
|
+
const job = currentJob(worker);
|
|
40
|
+
return job === undefined ? {} : { currentJob: job };
|
|
41
|
+
})(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Unit tests for the durable worker-attribution store (app/agentic/correlation-store.ts, #485).
|
|
2
|
+
// - drift guard: db/migrations/078 mirrors AGENTIC_CORRELATION_SCHEMA_SQL;
|
|
3
|
+
// - record/get/byStream round-trips, including the optional (nullable) engine-context columns;
|
|
4
|
+
// - upsert semantics (re-recording a jobKey is last-write-wins).
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
11
|
+
import { assert, assertEquals } from "#test-assert";
|
|
12
|
+
import { jobStream } from "./correlation.ts";
|
|
13
|
+
import { AGENTIC_CORRELATION_SCHEMA_SQL, AgenticCorrelationStore } from "./correlation-store.ts";
|
|
14
|
+
|
|
15
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
function memoryDb(): SqliteDb {
|
|
18
|
+
const raw = new DatabaseSync(":memory:");
|
|
19
|
+
return {
|
|
20
|
+
exec: (sql) => raw.exec(sql),
|
|
21
|
+
run: (sql, params = []) => raw.prepare(sql).run(...params),
|
|
22
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] =>
|
|
23
|
+
raw.prepare(sql).all(...params) as T[],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("drift guard: migration 078 mirrors AGENTIC_CORRELATION_SCHEMA_SQL", async () => {
|
|
28
|
+
const migrationPath = join(HERE, "..", "..", "db", "migrations", "078_agentic_correlation.sql");
|
|
29
|
+
const raw = await readFile(migrationPath, "utf8");
|
|
30
|
+
const ddl = raw
|
|
31
|
+
.split("\n")
|
|
32
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
33
|
+
.join("\n");
|
|
34
|
+
const normalise = (s: string) => s.trim().replace(/\s+/g, " ");
|
|
35
|
+
assertEquals(
|
|
36
|
+
normalise(ddl),
|
|
37
|
+
normalise(AGENTIC_CORRELATION_SCHEMA_SQL),
|
|
38
|
+
"078_agentic_correlation.sql drifted from AGENTIC_CORRELATION_SCHEMA_SQL",
|
|
39
|
+
);
|
|
40
|
+
assert(ddl.includes("agentic_correlation"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("record + get round-trips full attribution, and byStream decodes the jobKey", () => {
|
|
44
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
45
|
+
store.record({
|
|
46
|
+
jobKey: "job-1",
|
|
47
|
+
stream: jobStream("job-1"),
|
|
48
|
+
instance: "worker-A",
|
|
49
|
+
identity: "leaf:token",
|
|
50
|
+
host: "merlin.local",
|
|
51
|
+
processInstanceKey: "pi-9",
|
|
52
|
+
bpmnProcessId: "pr-flow",
|
|
53
|
+
elementId: "agent",
|
|
54
|
+
planKey: "owner/repo#142",
|
|
55
|
+
linkedAt: "2026-08-23T00:00:00.000Z",
|
|
56
|
+
completedAt: "2026-08-23T00:05:00.000Z",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const got = store.get("job-1");
|
|
60
|
+
assertEquals(got?.instance, "worker-A");
|
|
61
|
+
assertEquals(got?.identity, "leaf:token");
|
|
62
|
+
assertEquals(got?.host, "merlin.local");
|
|
63
|
+
assertEquals(got?.processInstanceKey, "pi-9");
|
|
64
|
+
assertEquals(got?.planKey, "owner/repo#142");
|
|
65
|
+
assertEquals(got?.completedAt, "2026-08-23T00:05:00.000Z");
|
|
66
|
+
// The same row is reachable from the stream id.
|
|
67
|
+
assertEquals(store.byStream(jobStream("job-1"))?.instance, "worker-A");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("optional context columns are omitted (not null) when unknown", () => {
|
|
71
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
72
|
+
store.record({
|
|
73
|
+
jobKey: "job-2",
|
|
74
|
+
stream: jobStream("job-2"),
|
|
75
|
+
instance: "worker-B",
|
|
76
|
+
completedAt: "2026-08-23T01:00:00.000Z",
|
|
77
|
+
});
|
|
78
|
+
const got = store.get("job-2");
|
|
79
|
+
assertEquals(got?.instance, "worker-B");
|
|
80
|
+
assert(!("processInstanceKey" in (got ?? {})), "unknown context is omitted, never a null key");
|
|
81
|
+
assert(!("identity" in (got ?? {})), "unknown identity is omitted");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("record is an upsert: re-recording a jobKey is last-write-wins", () => {
|
|
85
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
86
|
+
const base = { jobKey: "job-3", stream: jobStream("job-3"), completedAt: "2026-08-23T02:00:00.000Z" };
|
|
87
|
+
store.record({ ...base, instance: "worker-C" });
|
|
88
|
+
store.record({ ...base, instance: "worker-C", host: "second.local", completedAt: "2026-08-23T02:10:00.000Z" });
|
|
89
|
+
const got = store.get("job-3");
|
|
90
|
+
assertEquals(got?.host, "second.local");
|
|
91
|
+
assertEquals(got?.completedAt, "2026-08-23T02:10:00.000Z");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("get is undefined for an unknown jobKey and byStream undefined for a non-job stream", () => {
|
|
95
|
+
const store = new AgenticCorrelationStore(memoryDb());
|
|
96
|
+
assertEquals(store.get("nope"), undefined);
|
|
97
|
+
assertEquals(store.byStream("worker-A"), undefined);
|
|
98
|
+
assertEquals(store.get(""), undefined);
|
|
99
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// nano-workforce — the DURABLE jobKey ⇄ worker-attribution store (#485, provisioning #232).
|
|
2
|
+
//
|
|
3
|
+
// The in-memory {@link ./correlation.ts | CorrelationRegistry} is the live join, but it is RELEASED
|
|
4
|
+
// on job end / worker disconnect (`releaseJob` / `releaseInstance`) and is empty after a restart. So a
|
|
5
|
+
// COMPLETED (past) session — the exact case the cockpit "past sessions" / worker-history view reads —
|
|
6
|
+
// loses which worker ran it (instance / identity / host) and its process-instance / plan context: the
|
|
7
|
+
// live registry no longer holds the row, and the package-mirrored transcript store
|
|
8
|
+
// (`db/migrations/024_agentic_transcript.sql`, byte-for-byte guarded) carries no correlation columns.
|
|
9
|
+
//
|
|
10
|
+
// This app-side table closes that gap WITHOUT touching the mirrored transcript schema (exactly the
|
|
11
|
+
// shape #232 called for): at job-completion time the relay slice records the attribution here, keyed
|
|
12
|
+
// by jobKey, so the transcript read path can recover a past session's worker + context after the
|
|
13
|
+
// worker has exited. Advisory / read-only (ADR 0056) — it NEVER gates a BPMN sequence flow.
|
|
14
|
+
//
|
|
15
|
+
// Single source of truth: {@link AGENTIC_CORRELATION_SCHEMA_SQL} is the canonical DDL. It is applied
|
|
16
|
+
// idempotently on store construction (so unit tests over an in-memory DB have the table) AND mirrored
|
|
17
|
+
// byte-for-byte by the forward-only migration `db/migrations/078_agentic_correlation.sql`, which a
|
|
18
|
+
// drift-guard test (`correlation-store.test.ts`) pins so the two can never diverge.
|
|
19
|
+
import type { SqliteDb } from "@nanobpm/agentic/transcript";
|
|
20
|
+
import { jobKeyOfStream } from "./correlation.ts";
|
|
21
|
+
|
|
22
|
+
/** The canonical DDL for the durable correlation table. The `078_*` migration mirrors this exactly. */
|
|
23
|
+
export const AGENTIC_CORRELATION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS agentic_correlation (
|
|
24
|
+
job_key TEXT PRIMARY KEY,
|
|
25
|
+
stream TEXT NOT NULL,
|
|
26
|
+
instance TEXT NOT NULL,
|
|
27
|
+
identity TEXT,
|
|
28
|
+
host TEXT,
|
|
29
|
+
process_instance_key TEXT,
|
|
30
|
+
bpmn_process_id TEXT,
|
|
31
|
+
element_id TEXT,
|
|
32
|
+
plan_key TEXT,
|
|
33
|
+
linked_at TEXT,
|
|
34
|
+
completed_at TEXT NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
/** One durable attribution row: which worker ran a job, plus its (best-effort) engine context. */
|
|
42
|
+
export interface DurableCorrelation {
|
|
43
|
+
readonly jobKey: string;
|
|
44
|
+
readonly stream: string;
|
|
45
|
+
readonly instance: string;
|
|
46
|
+
readonly identity?: string;
|
|
47
|
+
readonly host?: string;
|
|
48
|
+
readonly processInstanceKey?: string;
|
|
49
|
+
readonly bpmnProcessId?: string;
|
|
50
|
+
readonly elementId?: string;
|
|
51
|
+
readonly planKey?: string;
|
|
52
|
+
/** When the job was first linked (its first `produce`), ISO-8601, when known. */
|
|
53
|
+
readonly linkedAt?: string;
|
|
54
|
+
/** When the job completed (was flushed / released), ISO-8601. */
|
|
55
|
+
readonly completedAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A row as stored (nullable columns come back as `null`). */
|
|
59
|
+
interface Row {
|
|
60
|
+
job_key: string;
|
|
61
|
+
stream: string;
|
|
62
|
+
instance: string;
|
|
63
|
+
identity: string | null;
|
|
64
|
+
host: string | null;
|
|
65
|
+
process_instance_key: string | null;
|
|
66
|
+
bpmn_process_id: string | null;
|
|
67
|
+
element_id: string | null;
|
|
68
|
+
plan_key: string | null;
|
|
69
|
+
linked_at: string | null;
|
|
70
|
+
completed_at: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fromRow(r: Row): DurableCorrelation {
|
|
74
|
+
const out: DurableCorrelation = {
|
|
75
|
+
jobKey: r.job_key,
|
|
76
|
+
stream: r.stream,
|
|
77
|
+
instance: r.instance,
|
|
78
|
+
completedAt: r.completed_at,
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
...out,
|
|
82
|
+
...(r.identity !== null ? { identity: r.identity } : {}),
|
|
83
|
+
...(r.host !== null ? { host: r.host } : {}),
|
|
84
|
+
...(r.process_instance_key !== null ? { processInstanceKey: r.process_instance_key } : {}),
|
|
85
|
+
...(r.bpmn_process_id !== null ? { bpmnProcessId: r.bpmn_process_id } : {}),
|
|
86
|
+
...(r.element_id !== null ? { elementId: r.element_id } : {}),
|
|
87
|
+
...(r.plan_key !== null ? { planKey: r.plan_key } : {}),
|
|
88
|
+
...(r.linked_at !== null ? { linkedAt: r.linked_at } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The durable worker-attribution store over the app's SQLite handle. Synchronous (mirrors
|
|
94
|
+
* {@link SqliteDb} and the relay slice's sync frame handling), advisory — a persistence failure never
|
|
95
|
+
* bubbles into a frame handler; callers wrap {@link record} defensively.
|
|
96
|
+
*/
|
|
97
|
+
export class AgenticCorrelationStore {
|
|
98
|
+
readonly #db: SqliteDb;
|
|
99
|
+
|
|
100
|
+
constructor(db: SqliteDb) {
|
|
101
|
+
this.#db = db;
|
|
102
|
+
// Idempotent — the migration applies the same DDL at boot; this makes the table present for
|
|
103
|
+
// unit tests over an in-memory DB (and is a no-op alongside the migration).
|
|
104
|
+
this.#db.exec(AGENTIC_CORRELATION_SCHEMA_SQL);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Upsert a completed job's attribution (last write wins on jobKey). */
|
|
108
|
+
record(entry: DurableCorrelation): void {
|
|
109
|
+
this.#db.run(
|
|
110
|
+
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
113
|
+
ON CONFLICT(job_key) DO UPDATE SET
|
|
114
|
+
stream = excluded.stream,
|
|
115
|
+
instance = excluded.instance,
|
|
116
|
+
identity = excluded.identity,
|
|
117
|
+
host = excluded.host,
|
|
118
|
+
process_instance_key = excluded.process_instance_key,
|
|
119
|
+
bpmn_process_id = excluded.bpmn_process_id,
|
|
120
|
+
element_id = excluded.element_id,
|
|
121
|
+
plan_key = excluded.plan_key,
|
|
122
|
+
linked_at = excluded.linked_at,
|
|
123
|
+
completed_at = excluded.completed_at`,
|
|
124
|
+
[
|
|
125
|
+
entry.jobKey,
|
|
126
|
+
entry.stream,
|
|
127
|
+
entry.instance,
|
|
128
|
+
entry.identity ?? null,
|
|
129
|
+
entry.host ?? null,
|
|
130
|
+
entry.processInstanceKey ?? null,
|
|
131
|
+
entry.bpmnProcessId ?? null,
|
|
132
|
+
entry.elementId ?? null,
|
|
133
|
+
entry.planKey ?? null,
|
|
134
|
+
entry.linkedAt ?? null,
|
|
135
|
+
entry.completedAt,
|
|
136
|
+
],
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The durable attribution for a jobKey, or undefined when none was recorded. */
|
|
141
|
+
get(jobKey: string): DurableCorrelation | undefined {
|
|
142
|
+
if (jobKey === "") return undefined;
|
|
143
|
+
const rows = this.#db.all<Row>("SELECT * FROM agentic_correlation WHERE job_key = ?", [jobKey]);
|
|
144
|
+
return rows.length > 0 ? fromRow(rows[0]) : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The durable attribution for a `job:<jobKey>` stream id, or undefined for a non-job stream. */
|
|
148
|
+
byStream(stream: string): DurableCorrelation | undefined {
|
|
149
|
+
const jobKey = jobKeyOfStream(stream);
|
|
150
|
+
return jobKey === undefined ? undefined : this.get(jobKey);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every durable attribution recorded for a worker instance, newest completion first. */
|
|
154
|
+
byInstance(instance: string): DurableCorrelation[] {
|
|
155
|
+
if (instance === "") return [];
|
|
156
|
+
const rows = this.#db.all<Row>(
|
|
157
|
+
"SELECT * FROM agentic_correlation WHERE instance = ? ORDER BY completed_at DESC, job_key DESC",
|
|
158
|
+
[instance],
|
|
159
|
+
);
|
|
160
|
+
return rows.map(fromRow);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -138,6 +138,18 @@ test("instanceForConnection: resolves the worker instance owning a connection (H
|
|
|
138
138
|
assertEquals(registry.instanceForConnection(""), undefined, "empty connection → undefined");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("attributionOf: resolves a worker instance's durable identity + host for job attribution (#485)", () => {
|
|
142
|
+
const store = createPresenceStore(memSqlite());
|
|
143
|
+
store.ensureSchema();
|
|
144
|
+
store.register({ instance: "w1", connectionId: "c1", identity: "leafA", capability: { host: "boxA1" } });
|
|
145
|
+
store.register({ instance: "w2", connectionId: "c2", identity: "leafB", capability: {} });
|
|
146
|
+
const registry = new PresenceRegistry(store, () => new Set(["c1", "c2"]));
|
|
147
|
+
assertEquals(registry.attributionOf("w1"), { identity: "leafA", host: "boxA1" });
|
|
148
|
+
assertEquals(registry.attributionOf("w2"), { identity: "leafB", host: undefined }, "no host → host undefined");
|
|
149
|
+
assertEquals(registry.attributionOf("nope"), undefined, "unknown instance → undefined");
|
|
150
|
+
assertEquals(registry.attributionOf(""), undefined, "empty instance → undefined");
|
|
151
|
+
});
|
|
152
|
+
|
|
141
153
|
test("reconcile: removes rows whose connection the hub has closed, keeps live ones", () => {
|
|
142
154
|
const store = createPresenceStore(memSqlite());
|
|
143
155
|
store.ensureSchema();
|
|
@@ -148,6 +148,20 @@ export class PresenceRegistry {
|
|
|
148
148
|
return undefined;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a worker instance's durable identity attributes (presence identity + host) for job
|
|
153
|
+
* attribution (#485). Returns the most recently registered matching row's attributes, or undefined
|
|
154
|
+
* when the instance is unknown (e.g. it already deregistered).
|
|
155
|
+
*/
|
|
156
|
+
attributionOf(instance: string): { identity?: string; host?: string } | undefined {
|
|
157
|
+
if (instance === "") return undefined;
|
|
158
|
+
let match: { identity?: string; host?: string } | undefined;
|
|
159
|
+
for (const row of this.#store.list()) {
|
|
160
|
+
if (row.instance === instance) match = { identity: row.identity, host: row.capability.host };
|
|
161
|
+
}
|
|
162
|
+
return match;
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
/**
|
|
152
166
|
* Eagerly drop presence rows whose connection the hub has already closed (a disconnect the hub's
|
|
153
167
|
* single close listener removed from its in-memory registry). Rows also age out on the presence
|
|
@@ -20,6 +20,7 @@ import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcrip
|
|
|
20
20
|
import { assert, assertEquals } from "#test-assert";
|
|
21
21
|
import { noopLog } from "../../../test/log.ts";
|
|
22
22
|
import { CorrelationRegistry, jobStream } from "../correlation.ts";
|
|
23
|
+
import { AgenticCorrelationStore } from "../correlation-store.ts";
|
|
23
24
|
import {
|
|
24
25
|
type CorrelationLink,
|
|
25
26
|
createRelayFamily,
|
|
@@ -139,6 +140,11 @@ function mkCorrelatedService(
|
|
|
139
140
|
db: SqliteDb | undefined,
|
|
140
141
|
correlation: CorrelationRegistry,
|
|
141
142
|
byConnection: Map<string, string>,
|
|
143
|
+
extra: {
|
|
144
|
+
attributionForInstance?: (instance: string) => { identity?: string; host?: string } | undefined;
|
|
145
|
+
correlationStore?: AgenticCorrelationStore;
|
|
146
|
+
now?: () => string;
|
|
147
|
+
} = {},
|
|
142
148
|
): { service: RelayTranscriptService; hub: CapturingHub } {
|
|
143
149
|
const hub = capturingHub();
|
|
144
150
|
const service = new RelayTranscriptService({
|
|
@@ -148,6 +154,9 @@ function mkCorrelatedService(
|
|
|
148
154
|
log: noopLog(),
|
|
149
155
|
correlation: () => correlation,
|
|
150
156
|
instanceForConnection: (id) => byConnection.get(id),
|
|
157
|
+
attributionForInstance: extra.attributionForInstance,
|
|
158
|
+
correlationStore: extra.correlationStore,
|
|
159
|
+
now: extra.now,
|
|
151
160
|
});
|
|
152
161
|
return { service, hub };
|
|
153
162
|
}
|
|
@@ -349,6 +358,69 @@ test("H6 correlation write-side: a late produce after completion does not resurr
|
|
|
349
358
|
service.teardown();
|
|
350
359
|
});
|
|
351
360
|
|
|
361
|
+
test("H6 correlation write-side: a worker starting a NEW job over its live connection supersedes its prior job", () => {
|
|
362
|
+
const registry = new ConnectionRegistry();
|
|
363
|
+
const correlation = new CorrelationRegistry();
|
|
364
|
+
// One worker, ONE long-lived connection — exactly the fleet reality: a worker relays every job it
|
|
365
|
+
// runs over the same channel connection, one job at a time (correlation.ts). The connection never
|
|
366
|
+
// disconnects between jobs, so the disconnect-driven #reconcile release never fires; without a
|
|
367
|
+
// supersede-on-next-job rule the worker's supply row would accumulate EVERY job it ever ran.
|
|
368
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
369
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
370
|
+
const p = connect("prod", registry);
|
|
371
|
+
|
|
372
|
+
// Job 1: the worker relays k1's terminal → linked.
|
|
373
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
374
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "job 1 is the current job");
|
|
375
|
+
|
|
376
|
+
// Job 2 begins on the SAME live connection (no disconnect). The worker relaying k2's terminal PROVES
|
|
377
|
+
// k1 finished (one job at a time) → k1 is superseded: released AND flushed to a durable past session.
|
|
378
|
+
hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
|
|
379
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k2"], "the supply row shows ONLY the current job — no accumulation");
|
|
380
|
+
assertEquals(correlation.resolve("k1"), undefined, "the prior job's correlation is released");
|
|
381
|
+
assertEquals(correlation.count(), 1);
|
|
382
|
+
|
|
383
|
+
// The superseded job is not lost — its transcript is flushed and completed, i.e. a replayable past session.
|
|
384
|
+
const priorMeta = service.transcriptOf(jobStream("k1"));
|
|
385
|
+
assertEquals(priorMeta?.status, "completed", "the superseded job becomes a completed past session");
|
|
386
|
+
assertEquals(service.reattach(jobStream("k1"), 0)?.entries.length, 1, "the past session replays its captured terminal");
|
|
387
|
+
service.teardown();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("H6 durable attribution: completing/superseding a job persists the worker's attribution to the correlation store", () => {
|
|
391
|
+
const registry = new ConnectionRegistry();
|
|
392
|
+
const correlation = new CorrelationRegistry();
|
|
393
|
+
const db = memoryDb();
|
|
394
|
+
const store = new AgenticCorrelationStore(db);
|
|
395
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
396
|
+
// The presence-backed resolver: worker-A's durable identity/host, available while it is registered
|
|
397
|
+
// but gone once it exits — which is exactly why the job's attribution must be persisted at completion.
|
|
398
|
+
const attributionForInstance = (instance: string) =>
|
|
399
|
+
instance === "worker-A" ? { identity: "gpu-box-7", host: "us-east-1a" } : undefined;
|
|
400
|
+
const { service, hub } = mkCorrelatedService(registry, db, correlation, byConnection, {
|
|
401
|
+
attributionForInstance,
|
|
402
|
+
correlationStore: store,
|
|
403
|
+
now: () => "2024-01-02T03:04:05.000Z",
|
|
404
|
+
});
|
|
405
|
+
const p = connect("prod", registry);
|
|
406
|
+
|
|
407
|
+
// Job 1 runs, then the worker starts job 2 on the same live connection → job 1 is superseded and
|
|
408
|
+
// released. Its attribution must be durably recorded BEFORE the live correlation forgets it.
|
|
409
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
410
|
+
hub.handler?.(produce(jobStream("k2"), 1, "job-2 line"), p.conn);
|
|
411
|
+
|
|
412
|
+
const durable = store.get("k1");
|
|
413
|
+
assert(durable !== undefined, "the superseded job's attribution is persisted");
|
|
414
|
+
assertEquals(durable?.instance, "worker-A");
|
|
415
|
+
assertEquals(durable?.identity, "gpu-box-7");
|
|
416
|
+
assertEquals(durable?.host, "us-east-1a");
|
|
417
|
+
assertEquals(durable?.stream, jobStream("k1"));
|
|
418
|
+
assertEquals(durable?.completedAt, "2024-01-02T03:04:05.000Z");
|
|
419
|
+
// The still-active job is NOT yet recorded (attribution is written on completion, not on link).
|
|
420
|
+
assertEquals(store.get("k2"), undefined, "the active job has no completion attribution yet");
|
|
421
|
+
service.teardown();
|
|
422
|
+
});
|
|
423
|
+
|
|
352
424
|
/**
|
|
353
425
|
* A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
|
|
354
426
|
* `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are
|