@nanobpm/nano-workforce 0.127.0 → 0.129.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 +14 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- 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/app/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/db/migrations/078_agentic_correlation.sql +32 -0
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/openapi.yaml +26 -0
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getAgenticTranscript.ts +3 -2
- package/operations/getLineage.test.ts +2 -1
- package/operations/listAgenticTranscripts.ts +2 -1
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +65 -2
- package/pages/cockpit/mount.js +187 -16
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
|
@@ -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
|
}
|
package/app/conformance.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
4
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "./blackboard.ts";
|
|
7
8
|
import {
|
|
8
9
|
acknowledgeConformance,
|
|
@@ -45,7 +46,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
49
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
49
50
|
return { data, stores };
|
|
50
51
|
}
|
|
51
52
|
|
package/app/conformance.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { DataLayer } from "@nanobpm/urban";
|
|
18
18
|
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
20
21
|
import { planTasks } from "./plan.ts";
|
|
21
22
|
|
|
22
23
|
const now = () => new Date().toISOString();
|
|
@@ -45,15 +46,20 @@ interface PlanRow extends Record<string, unknown> {
|
|
|
45
46
|
|
|
46
47
|
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
47
48
|
const prsTbl = (data: DataLayer) =>
|
|
48
|
-
|
|
49
|
+
derivedTrackingTable<{ pr_key: string; derived_status: string }>(
|
|
50
|
+
data,
|
|
51
|
+
"pull_requests",
|
|
52
|
+
"pr_key",
|
|
53
|
+
);
|
|
49
54
|
|
|
50
55
|
/** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
|
|
51
56
|
* predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
|
|
52
|
-
* the full digest and the cheap trigger check can't disagree about what counts as landed.
|
|
57
|
+
* the full digest and the cheap trigger check can't disagree about what counts as landed. Reads the
|
|
58
|
+
* ADR-0065 derived edge so an out-of-band-terminated PR is correctly excluded from "landed". */
|
|
53
59
|
async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
|
|
54
60
|
if (!prKey) return false;
|
|
55
61
|
const pr = await prsTbl(data).get(prKey);
|
|
56
|
-
return !!pr && LANDED_PR_STATUSES.has(pr.
|
|
62
|
+
return !!pr && LANDED_PR_STATUSES.has(pr.derived_status);
|
|
57
63
|
}
|
|
58
64
|
const conformanceTbl = (data: DataLayer) =>
|
|
59
65
|
data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assertEquals } from "#test-assert";
|
|
8
8
|
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import { deriveFeatureDelivery } from "./feature.ts";
|
|
10
11
|
import { pollFeatureDelivery } from "./service.ts";
|
|
11
12
|
|
|
@@ -34,7 +35,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
38
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
38
39
|
return { data, stores };
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// nano-workforce — the app's single accessor for the `instanceTracking` derived read models
|
|
2
|
+
// (ADR 0065, the writer→source inversion adopted with `@nanobpm/urban@0.81.0`).
|
|
3
|
+
//
|
|
4
|
+
// Since ADR 0065 the `instanceTracking` reconciler is a SOURCE, not a writer: on each poll it feeds
|
|
5
|
+
// engine truth into urban's canonical projections (`urban_instance_state`, `urban_open_user_tasks`)
|
|
6
|
+
// and NO LONGER writes the terminal (`onTerminated.set`) / wait-on-human (`onWaitingHuman.set`)
|
|
7
|
+
// edges onto the app's base row. Those edges are now DERIVED — recomputed on every read — by an
|
|
8
|
+
// auto-provisioned managed VIEW `<table>__tracking` whose `derived_status` column is
|
|
9
|
+
// `CASE WHEN terminated THEN <onTerminated value> WHEN waiting-human THEN <onWaitingHuman value>
|
|
10
|
+
// ELSE base.<statusField> END`. So the base `statusField` keeps only the worker-owned transient
|
|
11
|
+
// status, and any reader that used to rely on the reconciler having written the terminal status
|
|
12
|
+
// onto the base row must read `derived_status` off the VIEW instead.
|
|
13
|
+
//
|
|
14
|
+
// This module is the ONE place that:
|
|
15
|
+
// - parses the `instanceTracking` bindings from `nano.app.json` (the single source of truth), and
|
|
16
|
+
// - resolves each binding's derived VIEW name + `derived_status` column via urban's OWN target
|
|
17
|
+
// resolver (`instanceTrackingReadModelTarget`), so the app can never drift from the framework's
|
|
18
|
+
// view naming.
|
|
19
|
+
//
|
|
20
|
+
// Writers are unchanged: a service-task worker that owns a terminal outcome (`converged`, `merged`,
|
|
21
|
+
// `done`, …) still writes it to the base `data.table(<table>)`. Only readers that classify on the
|
|
22
|
+
// RECONCILER-derived edge (terminated → abandoned/failed/reviewed, or waiting-human →
|
|
23
|
+
// awaiting_operator) route through the derived VIEW here.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import {
|
|
27
|
+
type AppManifest,
|
|
28
|
+
type DataLayer,
|
|
29
|
+
type InstanceTracking,
|
|
30
|
+
instanceTrackingReadModelTarget,
|
|
31
|
+
type Table,
|
|
32
|
+
} from "@nanobpm/urban";
|
|
33
|
+
|
|
34
|
+
/** The app manifest, parsed exactly ONCE at module load, typed by urban's own `AppManifest` so the
|
|
35
|
+
* binding shape can never drift from the framework's schema. */
|
|
36
|
+
const APP_MANIFEST: AppManifest = JSON.parse(
|
|
37
|
+
readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** The app manifest's `instanceTracking` bindings — the single source of truth for the derived
|
|
41
|
+
* read-model registry. */
|
|
42
|
+
const INSTANCE_TRACKING_BINDINGS: readonly InstanceTracking[] = APP_MANIFEST.instanceTracking ?? [];
|
|
43
|
+
|
|
44
|
+
/** The single `instanceTracking` binding for a base table, or throw if the manifest has none. */
|
|
45
|
+
export function trackingBindingFor(table: string): InstanceTracking {
|
|
46
|
+
const binding = INSTANCE_TRACKING_BINDINGS.find((b) => b.table === table);
|
|
47
|
+
if (!binding) {
|
|
48
|
+
throw new Error(`nano.app.json: no instanceTracking binding for table "${table}"`);
|
|
49
|
+
}
|
|
50
|
+
return binding;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A tracked table's parked-and-active statuses, from the single source of truth
|
|
54
|
+
* (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
|
|
55
|
+
* from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
|
|
56
|
+
export function activeStatusesFor(table: string): readonly string[] {
|
|
57
|
+
const binding = trackingBindingFor(table);
|
|
58
|
+
if (!binding.activeStatuses?.length) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return binding.activeStatuses;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The managed derived read-model VIEW name + effective-status column for a base table, resolved by
|
|
67
|
+
* urban's OWN target resolver so the app never drifts from the framework's `<table>__tracking` /
|
|
68
|
+
* `derived_status` naming (ADR 0065). */
|
|
69
|
+
export function trackingTargetFor(table: string): { view: string; statusColumn: string } {
|
|
70
|
+
return instanceTrackingReadModelTarget(trackingBindingFor(table));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The base table a derived tracking VIEW projects, or undefined when `view` is not a tracking view.
|
|
74
|
+
* The inverse of {@link trackingTargetFor}, resolved off the same binding registry so it can't drift
|
|
75
|
+
* from the framework's view naming. */
|
|
76
|
+
export function baseTableForTrackingView(view: string): string | undefined {
|
|
77
|
+
return INSTANCE_TRACKING_BINDINGS.find((b) => trackingTargetFor(b.table).view === view)?.table;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The base `statusField` a binding's derived edge falls through to when no terminal/wait edge
|
|
81
|
+
* applies (the VIEW's `ELSE base.<statusField>` branch). Defaults to `"status"`, mirroring urban. */
|
|
82
|
+
export function baseStatusFieldFor(table: string): string {
|
|
83
|
+
return trackingBindingFor(table).statusField ?? "status";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A read-only typed gateway over a tracked table's derived VIEW (`<table>__tracking`). The VIEW
|
|
87
|
+
* re-exports `base.*` plus the derived `derived_status` column, so a row carries BOTH the base
|
|
88
|
+
* transient `<statusField>` and the effective (ADR-0065-derived) `derived_status`. Read
|
|
89
|
+
* `derived_status` to classify on the terminal / wait-on-human edge; urban forbids writing a VIEW,
|
|
90
|
+
* so use `data.table(<table>)` for writes. `T` should include `derived_status: string`. */
|
|
91
|
+
export function derivedTrackingTable<T extends object>(
|
|
92
|
+
data: DataLayer,
|
|
93
|
+
table: string,
|
|
94
|
+
pk: string,
|
|
95
|
+
): Table<T> {
|
|
96
|
+
return data.table<T>(trackingTargetFor(table).view, pk);
|
|
97
|
+
}
|
package/app/lineage.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assert, assertEquals } from "#test-assert";
|
|
8
8
|
import type { DataLayer } from "@nanobpm/urban";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import {
|
|
10
11
|
deriveLineage,
|
|
11
12
|
type LineagePr,
|
|
@@ -176,7 +177,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
176
177
|
},
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
180
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
180
181
|
return { data, stores };
|
|
181
182
|
}
|
|
182
183
|
|