@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
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
type TranscriptStream,
|
|
34
34
|
} from "@nanobpm/agentic/transcript";
|
|
35
35
|
import type { Logger } from "@nanobpm/urban";
|
|
36
|
-
import { currentCorrelation, type JobContext, jobKeyOfStream } from "../correlation.ts";
|
|
36
|
+
import { currentCorrelation, type JobContext, type JobCorrelation, jobKeyOfStream } from "../correlation.ts";
|
|
37
|
+
import { AgenticCorrelationStore } from "../correlation-store.ts";
|
|
37
38
|
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
38
39
|
import { currentPresenceRegistry } from "./presence.family.ts";
|
|
39
40
|
|
|
@@ -89,6 +90,13 @@ interface StreamState {
|
|
|
89
90
|
* instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
|
|
90
91
|
*/
|
|
91
92
|
linked: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The worker instance a `job:<jobKey>` stream was linked under (H6). Recorded so a stream's release
|
|
95
|
+
* (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
|
|
96
|
+
* supersede index, and so the "one job at a time per worker" supersede rule can identify the
|
|
97
|
+
* instance's prior job stream.
|
|
98
|
+
*/
|
|
99
|
+
instance?: string;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/**
|
|
@@ -99,6 +107,19 @@ interface StreamState {
|
|
|
99
107
|
export interface CorrelationLink {
|
|
100
108
|
link(instance: string, jobKey: string, context?: JobContext): void;
|
|
101
109
|
releaseJob(jobKey: string): void;
|
|
110
|
+
/**
|
|
111
|
+
* The (still-live) engine context for a jobKey, when the write-side exposes it. Optional so the
|
|
112
|
+
* minimal double in tests need not implement it; the real {@link CorrelationRegistry} does, and the
|
|
113
|
+
* relay slice reads it at completion to persist a job's process-instance / plan context durably
|
|
114
|
+
* before the correlation is released (#485).
|
|
115
|
+
*/
|
|
116
|
+
resolve?(jobKey: string): JobCorrelation | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A worker's durable identity attributes, resolved from the presence registry at completion time. */
|
|
120
|
+
export interface WorkerAttribution {
|
|
121
|
+
readonly identity?: string;
|
|
122
|
+
readonly host?: string;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
export interface RelayTranscriptServiceOptions {
|
|
@@ -137,6 +158,20 @@ export interface RelayTranscriptServiceOptions {
|
|
|
137
158
|
* ({@link currentPresenceRegistry}). A resolver returning undefined → no linking (advisory).
|
|
138
159
|
*/
|
|
139
160
|
readonly instanceForConnection?: (connectionId: string) => string | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a producing worker instance's durable identity attributes (identity / host) — read at
|
|
163
|
+
* job-completion time and persisted with the attribution so a PAST session stays attributable to a
|
|
164
|
+
* worker after it exits (#485). {@link createRelayFamily} wires it to the presence registry; omitted
|
|
165
|
+
* → attribution is recorded with instance only (still attributable), never an error.
|
|
166
|
+
*/
|
|
167
|
+
readonly attributionForInstance?: (instance: string) => WorkerAttribution | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* The durable worker-attribution store (#485). Omitted → constructed from {@link db} (absent db →
|
|
170
|
+
* no durable attribution). Injectable so a test can supply an in-memory store.
|
|
171
|
+
*/
|
|
172
|
+
readonly correlationStore?: AgenticCorrelationStore;
|
|
173
|
+
/** "Now" as an ISO-8601 instant, injectable for deterministic completion timestamps. */
|
|
174
|
+
readonly now?: () => string;
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
|
|
@@ -158,19 +193,41 @@ export class RelayTranscriptService {
|
|
|
158
193
|
/** The transcript store, or `undefined` when no DataLayer is mounted (relay still works, unpersisted). */
|
|
159
194
|
readonly store: TranscriptStore | undefined;
|
|
160
195
|
|
|
196
|
+
/** The durable worker-attribution store (#485), or `undefined` when unpersisted. Exposed so the read path can attribute released jobs. */
|
|
197
|
+
get correlationStore(): AgenticCorrelationStore | undefined {
|
|
198
|
+
return this.#correlationStore;
|
|
199
|
+
}
|
|
200
|
+
|
|
161
201
|
readonly #registry: ConnectionRegistry;
|
|
162
202
|
readonly #log: Logger;
|
|
163
203
|
readonly #streams = new Map<string, StreamState>();
|
|
204
|
+
/**
|
|
205
|
+
* The `job:<jobKey>` relay stream each worker instance is CURRENTLY relaying (H6, #149). A worker
|
|
206
|
+
* relays every job it runs over one long-lived channel connection, one job at a time
|
|
207
|
+
* (`../correlation.ts`), so that connection never disconnects between jobs — the disconnect-driven
|
|
208
|
+
* `#reconcile` release never fires. This index lets a NEW job's first `produce` supersede the
|
|
209
|
+
* worker's PRIOR job (complete its stream → flush transcript + release correlation), so a supply
|
|
210
|
+
* row shows only the current job instead of accumulating every job the connection ever ran.
|
|
211
|
+
*/
|
|
212
|
+
readonly #jobStreamByInstance = new Map<string, string>();
|
|
164
213
|
/** The correlation write-side accessor (H6, #149) — resolved per call so a late family mount wins. */
|
|
165
214
|
readonly #correlation: () => CorrelationLink | undefined;
|
|
166
215
|
/** The connection → producing-instance resolver (H6, #149). */
|
|
167
216
|
readonly #instanceForConnection: (connectionId: string) => string | undefined;
|
|
217
|
+
/** Resolve a worker instance's durable identity attributes for attribution (#485). */
|
|
218
|
+
readonly #attributionForInstance: (instance: string) => WorkerAttribution | undefined;
|
|
219
|
+
/** The durable worker-attribution store, or undefined when unpersisted (#485). */
|
|
220
|
+
readonly #correlationStore: AgenticCorrelationStore | undefined;
|
|
221
|
+
/** "Now" as an ISO-8601 instant (injectable for deterministic tests). */
|
|
222
|
+
readonly #now: () => string;
|
|
168
223
|
|
|
169
224
|
constructor(options: RelayTranscriptServiceOptions) {
|
|
170
225
|
this.#registry = options.registry;
|
|
171
226
|
this.#log = options.log;
|
|
172
227
|
this.#correlation = options.correlation ?? currentCorrelation;
|
|
173
228
|
this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
|
|
229
|
+
this.#attributionForInstance = options.attributionForInstance ?? (() => undefined);
|
|
230
|
+
this.#now = options.now ?? (() => new Date().toISOString());
|
|
174
231
|
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
175
232
|
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
176
233
|
// running the relay unpersisted rather than tearing down the whole agentic channel.
|
|
@@ -188,6 +245,21 @@ export class RelayTranscriptService {
|
|
|
188
245
|
}
|
|
189
246
|
this.store = store;
|
|
190
247
|
|
|
248
|
+
// The durable worker-attribution store (#485) — advisory, same failure posture as the transcript
|
|
249
|
+
// store: an unbuildable store falls back to no durable attribution, never a mount failure.
|
|
250
|
+
let correlationStore = options.correlationStore;
|
|
251
|
+
if (correlationStore === undefined && options.db) {
|
|
252
|
+
try {
|
|
253
|
+
correlationStore = new AgenticCorrelationStore(options.db);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.#log.warn("agentic correlation store unavailable — past sessions unattributed", {
|
|
256
|
+
err: String(err),
|
|
257
|
+
});
|
|
258
|
+
correlationStore = undefined;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.#correlationStore = correlationStore;
|
|
262
|
+
|
|
191
263
|
this.relay = new RelayHub({
|
|
192
264
|
...options.relay,
|
|
193
265
|
onFenced: (stream, incarnation, current) => {
|
|
@@ -357,8 +429,17 @@ export class RelayTranscriptService {
|
|
|
357
429
|
const correlation = this.#correlation();
|
|
358
430
|
if (!correlation) return;
|
|
359
431
|
try {
|
|
432
|
+
// One job at a time per worker (`../correlation.ts`): the worker relaying a NEW job's terminal
|
|
433
|
+
// over its live connection PROVES its prior job finished. Supersede it — complete the prior
|
|
434
|
+
// stream (flush transcript → durable past session, release its correlation) BEFORE linking the
|
|
435
|
+
// new one — so the worker's supply row never accumulates jobs the disconnect-driven release
|
|
436
|
+
// would otherwise strand behind a connection that stays open across jobs.
|
|
437
|
+
const priorStream = this.#jobStreamByInstance.get(instance);
|
|
438
|
+
if (priorStream !== undefined && priorStream !== stream) this.completeStream(priorStream);
|
|
360
439
|
correlation.link(instance, jobKey);
|
|
361
440
|
state.linked = true;
|
|
441
|
+
state.instance = instance;
|
|
442
|
+
this.#jobStreamByInstance.set(instance, stream);
|
|
362
443
|
} catch (err) {
|
|
363
444
|
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
364
445
|
// leave the stream UNLINKED so a later `produce` retries the link.
|
|
@@ -376,8 +457,17 @@ export class RelayTranscriptService {
|
|
|
376
457
|
const jobKey = jobKeyOfStream(stream);
|
|
377
458
|
if (jobKey === undefined) return;
|
|
378
459
|
try {
|
|
460
|
+
// Persist the completed job's durable worker attribution + (best-effort) engine context BEFORE
|
|
461
|
+
// releasing the live correlation, so a PAST session stays attributable to a worker after it
|
|
462
|
+
// exits (#485) — the in-memory registry is about to forget it. Advisory, best-effort.
|
|
463
|
+
this.#persistAttribution(stream, jobKey, state);
|
|
379
464
|
this.#correlation()?.releaseJob(jobKey);
|
|
380
465
|
state.linked = false;
|
|
466
|
+
// Tidy the supersede index so it never points a released instance at a completed stream and
|
|
467
|
+
// stays bounded across the worker's lifetime.
|
|
468
|
+
if (state.instance !== undefined && this.#jobStreamByInstance.get(state.instance) === stream) {
|
|
469
|
+
this.#jobStreamByInstance.delete(state.instance);
|
|
470
|
+
}
|
|
381
471
|
} catch (err) {
|
|
382
472
|
// Advisory — never throws into the frame handler. Swallow a throwing injectable correlation and
|
|
383
473
|
// leave `state.linked` true so the flag honestly records that the release did NOT happen (rather
|
|
@@ -393,6 +483,41 @@ export class RelayTranscriptService {
|
|
|
393
483
|
}
|
|
394
484
|
}
|
|
395
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Record a completed job's durable attribution (#485): which worker ran it (instance + presence
|
|
488
|
+
* identity/host) and its still-live engine context (process instance / plan), keyed by jobKey, so
|
|
489
|
+
* the transcript read path can attribute the PAST session after the live correlation is released
|
|
490
|
+
* and after a restart. Advisory — a persistence fault is logged, never thrown into the frame
|
|
491
|
+
* handler, and never blocks the correlation release.
|
|
492
|
+
*/
|
|
493
|
+
#persistAttribution(stream: string, jobKey: string, state: StreamState): void {
|
|
494
|
+
const store = this.#correlationStore;
|
|
495
|
+
const instance = state.instance;
|
|
496
|
+
if (store === undefined || instance === undefined || instance === "") return;
|
|
497
|
+
try {
|
|
498
|
+
const attribution = this.#attributionForInstance(instance) ?? {};
|
|
499
|
+
const context = this.#correlation()?.resolve?.(jobKey);
|
|
500
|
+
store.record({
|
|
501
|
+
jobKey,
|
|
502
|
+
stream,
|
|
503
|
+
instance,
|
|
504
|
+
completedAt: this.#now(),
|
|
505
|
+
...(attribution.identity !== undefined ? { identity: attribution.identity } : {}),
|
|
506
|
+
...(attribution.host !== undefined ? { host: attribution.host } : {}),
|
|
507
|
+
...(context?.processInstanceKey !== undefined ? { processInstanceKey: context.processInstanceKey } : {}),
|
|
508
|
+
...(context?.bpmnProcessId !== undefined ? { bpmnProcessId: context.bpmnProcessId } : {}),
|
|
509
|
+
...(context?.elementId !== undefined ? { elementId: context.elementId } : {}),
|
|
510
|
+
...(context?.planKey !== undefined ? { planKey: context.planKey } : {}),
|
|
511
|
+
});
|
|
512
|
+
} catch (err) {
|
|
513
|
+
this.#log.warn("agentic correlation attribution persist failed — past session left unattributed", {
|
|
514
|
+
stream,
|
|
515
|
+
jobKey,
|
|
516
|
+
err: String(err),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
396
521
|
/**
|
|
397
522
|
* Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
|
|
398
523
|
* registry dropped it on close or liveness timeout), and release its job correlation (H6, #149).
|
|
@@ -460,6 +585,10 @@ export function createRelayFamily(options: {
|
|
|
460
585
|
// are read per call, so this works regardless of family mount order (relay may mount before
|
|
461
586
|
// presence/correlation). Absent registries → no linking, still advisory-correct.
|
|
462
587
|
instanceForConnection: (connectionId) => currentPresenceRegistry()?.instanceForConnection(connectionId),
|
|
588
|
+
// #485: resolve a completed job's worker attribution (presence identity/host) from the live
|
|
589
|
+
// presence registry, read per call for the same mount-order independence. Absent → attribution
|
|
590
|
+
// records instance only.
|
|
591
|
+
attributionForInstance: (instance) => currentPresenceRegistry()?.attributionOf(instance),
|
|
463
592
|
correlation: currentCorrelation,
|
|
464
593
|
});
|
|
465
594
|
setCurrentRelayTranscriptService(service);
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// time-window semantics directly on listTranscripts() — inclusive boundaries, ordering, and the
|
|
6
6
|
// interaction with a missing/invalid createdAt — where a hand-built store lets us fix exact timestamps.
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import type { SqliteDb, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import { AgenticCorrelationStore } from "./correlation-store.ts";
|
|
12
|
+
import { correlationFieldsFor, listTranscripts } from "./transcript-read.ts";
|
|
11
13
|
|
|
12
14
|
/** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
|
|
13
15
|
function fakeStore(metas: TranscriptStream[]): TranscriptStore {
|
|
@@ -70,3 +72,53 @@ test("listTranscripts: a session with an unparseable createdAt is retained regar
|
|
|
70
72
|
const out = listTranscripts(store, undefined, { since: late });
|
|
71
73
|
assertEquals(new Set(out.map((t) => t.stream)), new Set(["job:bad"]));
|
|
72
74
|
});
|
|
75
|
+
|
|
76
|
+
function memoryStore(): SqliteDb {
|
|
77
|
+
const raw = new DatabaseSync(":memory:");
|
|
78
|
+
return {
|
|
79
|
+
exec: (sql) => raw.exec(sql),
|
|
80
|
+
run: (sql, params = []) => raw.prepare(sql).run(...params),
|
|
81
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] =>
|
|
82
|
+
raw.prepare(sql).all(...params) as T[],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test("durable fallback: a released (past) job is attributed from the durable store when the live registry is empty", () => {
|
|
87
|
+
// The exact past-session case: the live correlation registry no longer holds the job (undefined here),
|
|
88
|
+
// so worker attribution + context must come from the durable store recorded at completion.
|
|
89
|
+
const durable = new AgenticCorrelationStore(memoryStore());
|
|
90
|
+
durable.record({
|
|
91
|
+
jobKey: "k1",
|
|
92
|
+
stream: "job:k1",
|
|
93
|
+
instance: "worker-A",
|
|
94
|
+
identity: "gpu-box-7",
|
|
95
|
+
host: "us-east-1a",
|
|
96
|
+
processInstanceKey: "pi-9",
|
|
97
|
+
planKey: "acme/repo#42",
|
|
98
|
+
completedAt: mid,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const fields = correlationFieldsFor("job:k1", undefined, durable);
|
|
102
|
+
assertEquals(fields.jobKey, "k1");
|
|
103
|
+
assertEquals(fields.instance, "worker-A");
|
|
104
|
+
assertEquals(fields.identity, "gpu-box-7");
|
|
105
|
+
assertEquals(fields.host, "us-east-1a");
|
|
106
|
+
assertEquals(fields.processInstanceKey, "pi-9");
|
|
107
|
+
assertEquals(fields.planKey, "acme/repo#42");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("listTranscripts: the instance filter returns only sessions the durable store attributes to that worker", () => {
|
|
111
|
+
const store = fakeStore([meta("job:k1", early), meta("job:k2", mid), meta("job:k3", late)]);
|
|
112
|
+
const durable = new AgenticCorrelationStore(memoryStore());
|
|
113
|
+
durable.record({ jobKey: "k1", stream: "job:k1", instance: "worker-A", completedAt: early });
|
|
114
|
+
durable.record({ jobKey: "k2", stream: "job:k2", instance: "worker-B", completedAt: mid });
|
|
115
|
+
durable.record({ jobKey: "k3", stream: "job:k3", instance: "worker-A", completedAt: late });
|
|
116
|
+
|
|
117
|
+
const out = listTranscripts(store, undefined, { instance: "worker-A" }, durable);
|
|
118
|
+
assertEquals(
|
|
119
|
+
out.map((t) => t.stream),
|
|
120
|
+
["job:k3", "job:k1"],
|
|
121
|
+
"only worker-A's sessions, newest-first",
|
|
122
|
+
);
|
|
123
|
+
assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
|
|
124
|
+
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
18
|
import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
|
|
19
19
|
import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
|
|
20
|
+
import type { AgenticCorrelationStore } from "./correlation-store.ts";
|
|
20
21
|
import { utf8ByteLength } from "./transcript-events.ts";
|
|
21
22
|
|
|
22
23
|
/** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
|
|
@@ -26,21 +27,34 @@ export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
|
|
|
26
27
|
return total;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/** The correlation fields (jobKey + engine context) a stream id resolves to, best-effort. */
|
|
30
|
+
/** The correlation fields (jobKey + engine context + worker attribution) a stream id resolves to, best-effort. */
|
|
30
31
|
interface CorrelationFields {
|
|
31
32
|
jobKey?: string;
|
|
32
33
|
processInstanceKey?: string;
|
|
33
34
|
bpmnProcessId?: string;
|
|
34
35
|
elementId?: string;
|
|
35
36
|
planKey?: string;
|
|
37
|
+
/** The worker instance that ran the job (durable — survives release / restart). */
|
|
38
|
+
instance?: string;
|
|
39
|
+
/** The worker's durable identity, when recorded. */
|
|
40
|
+
identity?: string;
|
|
41
|
+
/** The worker's host, when recorded. */
|
|
42
|
+
host?: string;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
|
-
* Resolve a stream id to its correlation fields
|
|
40
|
-
* stream id
|
|
41
|
-
*
|
|
46
|
+
* Resolve a stream id to its correlation fields. The jobKey is always decoded from a `job:<jobKey>`
|
|
47
|
+
* stream id. Engine context (process instance / plan) + worker attribution (instance / identity /
|
|
48
|
+
* host) come from the LIVE registry while the job is still linked, and fall back to the DURABLE store
|
|
49
|
+
* (`AgenticCorrelationStore`) once the job has completed and its live correlation was released — so a
|
|
50
|
+
* PAST session stays attributable to its worker after the worker exits or the process restarts.
|
|
51
|
+
* Non-job streams yield an empty object.
|
|
42
52
|
*/
|
|
43
|
-
export function correlationFieldsFor(
|
|
53
|
+
export function correlationFieldsFor(
|
|
54
|
+
stream: string,
|
|
55
|
+
correlation: CorrelationRegistry | undefined,
|
|
56
|
+
durable?: AgenticCorrelationStore | undefined,
|
|
57
|
+
): CorrelationFields {
|
|
44
58
|
const jobKey = jobKeyOfStream(stream);
|
|
45
59
|
if (jobKey === undefined) return {};
|
|
46
60
|
const fields: CorrelationFields = { jobKey };
|
|
@@ -51,6 +65,20 @@ export function correlationFieldsFor(stream: string, correlation: CorrelationReg
|
|
|
51
65
|
if (context.elementId !== undefined) fields.elementId = context.elementId;
|
|
52
66
|
if (context.planKey !== undefined) fields.planKey = context.planKey;
|
|
53
67
|
}
|
|
68
|
+
// Durable fallback: fill any field the live registry did not supply (a released past session, or a
|
|
69
|
+
// worker-attribution field the in-memory registry never carried). Live values take precedence.
|
|
70
|
+
const row = durable?.get(jobKey);
|
|
71
|
+
if (row) {
|
|
72
|
+
if (fields.processInstanceKey === undefined && row.processInstanceKey !== undefined) {
|
|
73
|
+
fields.processInstanceKey = row.processInstanceKey;
|
|
74
|
+
}
|
|
75
|
+
if (fields.bpmnProcessId === undefined && row.bpmnProcessId !== undefined) fields.bpmnProcessId = row.bpmnProcessId;
|
|
76
|
+
if (fields.elementId === undefined && row.elementId !== undefined) fields.elementId = row.elementId;
|
|
77
|
+
if (fields.planKey === undefined && row.planKey !== undefined) fields.planKey = row.planKey;
|
|
78
|
+
if (row.instance !== undefined) fields.instance = row.instance;
|
|
79
|
+
if (row.identity !== undefined) fields.identity = row.identity;
|
|
80
|
+
if (row.host !== undefined) fields.host = row.host;
|
|
81
|
+
}
|
|
54
82
|
return fields;
|
|
55
83
|
}
|
|
56
84
|
|
|
@@ -59,6 +87,7 @@ export function toTranscript(
|
|
|
59
87
|
meta: TranscriptStream,
|
|
60
88
|
store: TranscriptStore,
|
|
61
89
|
correlation: CorrelationRegistry | undefined,
|
|
90
|
+
durable?: AgenticCorrelationStore | undefined,
|
|
62
91
|
): AgenticTranscript {
|
|
63
92
|
const chunks = store.read(meta.stream);
|
|
64
93
|
const out: AgenticTranscript = {
|
|
@@ -72,12 +101,15 @@ export function toTranscript(
|
|
|
72
101
|
};
|
|
73
102
|
if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
|
|
74
103
|
if (meta.firstOffset !== undefined) out.firstOffset = meta.firstOffset;
|
|
75
|
-
const fields = correlationFieldsFor(meta.stream, correlation);
|
|
104
|
+
const fields = correlationFieldsFor(meta.stream, correlation, durable);
|
|
76
105
|
if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
|
|
77
106
|
if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
|
|
78
107
|
if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
|
|
79
108
|
if (fields.elementId !== undefined) out.elementId = fields.elementId;
|
|
80
109
|
if (fields.planKey !== undefined) out.planKey = fields.planKey;
|
|
110
|
+
if (fields.instance !== undefined) out.instance = fields.instance;
|
|
111
|
+
if (fields.identity !== undefined) out.identity = fields.identity;
|
|
112
|
+
if (fields.host !== undefined) out.host = fields.host;
|
|
81
113
|
return out;
|
|
82
114
|
}
|
|
83
115
|
|
|
@@ -86,6 +118,8 @@ export interface TranscriptFilter {
|
|
|
86
118
|
readonly jobKey?: string;
|
|
87
119
|
readonly processInstanceKey?: string;
|
|
88
120
|
readonly planKey?: string;
|
|
121
|
+
/** The worker instance that ran the session (durable attribution) — powers the worker-history view. */
|
|
122
|
+
readonly instance?: string;
|
|
89
123
|
/** ISO-8601 lower bound (inclusive) on the session's createdAt. */
|
|
90
124
|
readonly since?: string;
|
|
91
125
|
/** ISO-8601 upper bound (inclusive) on the session's createdAt. */
|
|
@@ -95,22 +129,24 @@ export interface TranscriptFilter {
|
|
|
95
129
|
/**
|
|
96
130
|
* List every captured session projected to the wire shape, sorted newest-first by createdAt (then by
|
|
97
131
|
* stream for a stable tie-break), after applying the (advisory) filters. jobKey / process-instance /
|
|
98
|
-
* plan filters match the correlation-enriched fields; since/until bound createdAt.
|
|
132
|
+
* plan / instance filters match the correlation-enriched fields; since/until bound createdAt.
|
|
99
133
|
*/
|
|
100
134
|
export function listTranscripts(
|
|
101
135
|
store: TranscriptStore,
|
|
102
136
|
correlation: CorrelationRegistry | undefined,
|
|
103
137
|
filter: TranscriptFilter = {},
|
|
138
|
+
durable?: AgenticCorrelationStore | undefined,
|
|
104
139
|
): AgenticTranscript[] {
|
|
105
140
|
const sinceMs = filter.since !== undefined ? Date.parse(filter.since) : undefined;
|
|
106
141
|
const untilMs = filter.until !== undefined ? Date.parse(filter.until) : undefined;
|
|
107
142
|
const rows = store
|
|
108
143
|
.list()
|
|
109
|
-
.map((meta) => toTranscript(meta, store, correlation))
|
|
144
|
+
.map((meta) => toTranscript(meta, store, correlation, durable))
|
|
110
145
|
.filter((t) => {
|
|
111
146
|
if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
|
|
112
147
|
if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
|
|
113
148
|
if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
|
|
149
|
+
if (filter.instance !== undefined && t.instance !== filter.instance) return false;
|
|
114
150
|
const createdMs = Date.parse(t.createdAt);
|
|
115
151
|
if (sinceMs !== undefined && Number.isFinite(createdMs) && createdMs < sinceMs) return false;
|
|
116
152
|
if (untilMs !== undefined && Number.isFinite(createdMs) && createdMs > untilMs) return false;
|
|
@@ -134,6 +170,7 @@ export function readTranscriptFrom(
|
|
|
134
170
|
from: number,
|
|
135
171
|
store: TranscriptStore,
|
|
136
172
|
correlation: CorrelationRegistry | undefined,
|
|
173
|
+
durable?: AgenticCorrelationStore | undefined,
|
|
137
174
|
): AgenticTranscriptData | undefined {
|
|
138
175
|
const meta = store.get(stream);
|
|
139
176
|
if (meta === undefined) return undefined;
|
|
@@ -152,11 +189,14 @@ export function readTranscriptFrom(
|
|
|
152
189
|
entries,
|
|
153
190
|
};
|
|
154
191
|
if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
|
|
155
|
-
const fields = correlationFieldsFor(meta.stream, correlation);
|
|
192
|
+
const fields = correlationFieldsFor(meta.stream, correlation, durable);
|
|
156
193
|
if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
|
|
157
194
|
if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
|
|
158
195
|
if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
|
|
159
196
|
if (fields.elementId !== undefined) out.elementId = fields.elementId;
|
|
160
197
|
if (fields.planKey !== undefined) out.planKey = fields.planKey;
|
|
198
|
+
if (fields.instance !== undefined) out.instance = fields.instance;
|
|
199
|
+
if (fields.identity !== undefined) out.identity = fields.identity;
|
|
200
|
+
if (fields.host !== undefined) out.host = fields.host;
|
|
161
201
|
return out;
|
|
162
202
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Durable per-job worker attribution + engine context (#485, provisioning #232).
|
|
2
|
+
--
|
|
3
|
+
-- The in-memory correlation registry (app/agentic/correlation.ts) is the live jobKey ⇄ worker join,
|
|
4
|
+
-- but it is RELEASED on job end / worker disconnect and is empty after a restart. So a COMPLETED
|
|
5
|
+
-- (past) session — what the cockpit "past sessions" / worker-history view reads — otherwise loses
|
|
6
|
+
-- which worker ran it (instance / identity / host) and its process-instance / plan context. The
|
|
7
|
+
-- package-mirrored transcript store (024_agentic_transcript.sql, byte-for-byte guarded) carries no
|
|
8
|
+
-- correlation columns, so this app-side table closes the gap WITHOUT touching that mirrored schema.
|
|
9
|
+
--
|
|
10
|
+
-- The relay slice records a row here at job-completion time; the transcript read path falls back to
|
|
11
|
+
-- it when the live registry has released the job. Advisory / read-only (ADR 0056) — it NEVER gates a
|
|
12
|
+
-- BPMN sequence flow.
|
|
13
|
+
--
|
|
14
|
+
-- Single source of truth: this DDL mirrors AGENTIC_CORRELATION_SCHEMA_SQL in
|
|
15
|
+
-- app/agentic/correlation-store.ts byte-for-byte; a drift-guard test (correlation-store.test.ts) pins
|
|
16
|
+
-- the two together.
|
|
17
|
+
CREATE TABLE IF NOT EXISTS agentic_correlation (
|
|
18
|
+
job_key TEXT PRIMARY KEY,
|
|
19
|
+
stream TEXT NOT NULL,
|
|
20
|
+
instance TEXT NOT NULL,
|
|
21
|
+
identity TEXT,
|
|
22
|
+
host TEXT,
|
|
23
|
+
process_instance_key TEXT,
|
|
24
|
+
bpmn_process_id TEXT,
|
|
25
|
+
element_id TEXT,
|
|
26
|
+
plan_key TEXT,
|
|
27
|
+
linked_at TEXT,
|
|
28
|
+
completed_at TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
|
package/openapi.yaml
CHANGED
|
@@ -646,6 +646,16 @@ components:
|
|
|
646
646
|
planKey:
|
|
647
647
|
type: string
|
|
648
648
|
description: The plan / epic key this job was part of (e.g. owner/repo#142), when still known (advisory).
|
|
649
|
+
instance:
|
|
650
|
+
type: string
|
|
651
|
+
description: The worker instance that ran the session, recovered from durable attribution — present even
|
|
652
|
+
after the worker exited or the process restarted (advisory).
|
|
653
|
+
identity:
|
|
654
|
+
type: string
|
|
655
|
+
description: The worker's durable identity (presence identity), when recorded (advisory).
|
|
656
|
+
host:
|
|
657
|
+
type: string
|
|
658
|
+
description: The worker's host, when recorded (advisory).
|
|
649
659
|
AgenticTranscriptList:
|
|
650
660
|
type: object
|
|
651
661
|
description: The list of captured agent sessions (past + open) — the cockpit "past sessions" feed.
|
|
@@ -742,6 +752,15 @@ components:
|
|
|
742
752
|
planKey:
|
|
743
753
|
type: string
|
|
744
754
|
description: The plan / epic key, when still known (advisory).
|
|
755
|
+
instance:
|
|
756
|
+
type: string
|
|
757
|
+
description: The worker instance that ran the session, from durable attribution (advisory).
|
|
758
|
+
identity:
|
|
759
|
+
type: string
|
|
760
|
+
description: The worker's durable identity, when recorded (advisory).
|
|
761
|
+
host:
|
|
762
|
+
type: string
|
|
763
|
+
description: The worker's host, when recorded (advisory).
|
|
745
764
|
entries:
|
|
746
765
|
type: array
|
|
747
766
|
description: The retained chunks with `offset >= from`, in offset order.
|
|
@@ -2448,6 +2467,13 @@ paths:
|
|
|
2448
2467
|
schema:
|
|
2449
2468
|
type: string
|
|
2450
2469
|
description: Return only transcripts whose (still-known) correlation names this plan / epic key.
|
|
2470
|
+
- name: instance
|
|
2471
|
+
in: query
|
|
2472
|
+
required: false
|
|
2473
|
+
schema:
|
|
2474
|
+
type: string
|
|
2475
|
+
description: Return only sessions run by this worker instance (durable attribution) — powers the
|
|
2476
|
+
per-worker history view. Survives worker exit / process restart.
|
|
2451
2477
|
- name: since
|
|
2452
2478
|
in: query
|
|
2453
2479
|
required: false
|
|
@@ -28,13 +28,14 @@ export default defineOperation("getAgenticTranscript", async ({ params, query, r
|
|
|
28
28
|
return { status: 400, body: { error: "invalid from: expected a non-negative integer offset" } };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const
|
|
31
|
+
const service = currentRelayTranscriptService();
|
|
32
|
+
const store = service?.store;
|
|
32
33
|
if (!store) {
|
|
33
34
|
// No transcript store mounted (relay unmounted or unpersisted) - nothing to replay.
|
|
34
35
|
return { status: 404, body: { error: "no transcript for stream" } };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const data = readTranscriptFrom(params.stream, from, store, currentCorrelation());
|
|
38
|
+
const data = readTranscriptFrom(params.stream, from, store, currentCorrelation(), service?.correlationStore);
|
|
38
39
|
if (data === undefined) {
|
|
39
40
|
return { status: 404, body: { error: "no transcript for stream" } };
|
|
40
41
|
}
|
|
@@ -47,10 +47,11 @@ export default defineOperation("listAgenticTranscripts", async ({ query, req },
|
|
|
47
47
|
...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
|
|
48
48
|
...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
|
|
49
49
|
...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
|
|
50
|
+
...(query.instance !== undefined ? { instance: query.instance } : {}),
|
|
50
51
|
...(query.since !== undefined ? { since: query.since } : {}),
|
|
51
52
|
...(query.until !== undefined ? { until: query.until } : {}),
|
|
52
53
|
};
|
|
53
|
-
const transcripts = listTranscripts(store, currentCorrelation(), filter);
|
|
54
|
+
const transcripts = listTranscripts(store, currentCorrelation(), filter, service?.correlationStore);
|
|
54
55
|
const body: AgenticTranscriptList = {
|
|
55
56
|
count: transcripts.length,
|
|
56
57
|
generatedAt: new Date().toISOString(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.128.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",
|
|
@@ -119,7 +119,10 @@
|
|
|
119
119
|
.cockpit-dot[data-liveness="stale"] { background: var(--cockpit-amber); }
|
|
120
120
|
.cockpit-dot[data-liveness="down"] { background: var(--cockpit-red); }
|
|
121
121
|
|
|
122
|
-
.cockpit-worker
|
|
122
|
+
.cockpit-worker,
|
|
123
|
+
.cockpit-worker-drill,
|
|
124
|
+
.cockpit-worker-detail-back,
|
|
125
|
+
.cockpit-worker-current-job {
|
|
123
126
|
background: none;
|
|
124
127
|
border: none;
|
|
125
128
|
color: var(--cockpit-text);
|
|
@@ -130,7 +133,15 @@
|
|
|
130
133
|
text-underline-offset: 2px;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
.cockpit-worker
|
|
136
|
+
.cockpit-worker-drill {
|
|
137
|
+
color: var(--cockpit-muted);
|
|
138
|
+
font-size: 12px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.cockpit-worker:hover,
|
|
142
|
+
.cockpit-worker-drill:hover,
|
|
143
|
+
.cockpit-worker-detail-back:hover,
|
|
144
|
+
.cockpit-worker-current-job:hover { color: #58a6ff; }
|
|
134
145
|
|
|
135
146
|
.cockpit-supply-process { color: var(--cockpit-muted); }
|
|
136
147
|
|
|
@@ -220,6 +231,58 @@
|
|
|
220
231
|
padding: 8px 0;
|
|
221
232
|
}
|
|
222
233
|
|
|
234
|
+
/* ── Worker detail route (#/cockpit/worker/<instance>): header + current job + filtered history. ── */
|
|
235
|
+
|
|
236
|
+
.cockpit-worker-detail {
|
|
237
|
+
display: grid;
|
|
238
|
+
gap: 12px;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.cockpit-worker-detail-back {
|
|
242
|
+
justify-self: start;
|
|
243
|
+
color: var(--cockpit-muted);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.cockpit-worker-detail-header {
|
|
247
|
+
display: grid;
|
|
248
|
+
gap: 8px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.cockpit-worker-detail-meta {
|
|
252
|
+
display: grid;
|
|
253
|
+
gap: 8px;
|
|
254
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
255
|
+
margin: 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.cockpit-worker-detail-meta-item {
|
|
259
|
+
border: 1px solid rgba(34, 48, 65, 0.7);
|
|
260
|
+
border-radius: 6px;
|
|
261
|
+
padding: 8px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.cockpit-worker-detail-meta-item dt {
|
|
265
|
+
color: var(--cockpit-muted);
|
|
266
|
+
font-size: 11px;
|
|
267
|
+
text-transform: uppercase;
|
|
268
|
+
letter-spacing: 0.04em;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.cockpit-worker-detail-meta-item dd {
|
|
272
|
+
margin: 2px 0 0;
|
|
273
|
+
overflow-wrap: anywhere;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.cockpit-worker-current {
|
|
277
|
+
border-top: 1px solid rgba(34, 48, 65, 0.7);
|
|
278
|
+
padding-top: 10px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.cockpit-worker-current-empty,
|
|
282
|
+
.cockpit-worker-detail-empty {
|
|
283
|
+
color: var(--cockpit-muted);
|
|
284
|
+
}
|
|
285
|
+
|
|
223
286
|
/* Distinguish a live terminal from a replayed (static) past session at a glance. */
|
|
224
287
|
.cockpit-terminal[data-terminal-mode="replay"] {
|
|
225
288
|
border-color: #8957e5;
|