@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.
Files changed (51) hide show
  1. package/.github/workflows/release.yml +29 -6
  2. package/AGENTS.md +19 -0
  3. package/CHANGELOG.md +14 -0
  4. package/app/abandon.test.ts +16 -2
  5. package/app/abandon.ts +39 -17
  6. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  7. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  8. package/app/agentic/cockpit/index.ts +16 -0
  9. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  10. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  11. package/app/agentic/cockpit/supply-boot.ts +76 -10
  12. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  13. package/app/agentic/cockpit/supply-render.ts +14 -2
  14. package/app/agentic/cockpit/transcript-render.ts +6 -2
  15. package/app/agentic/cockpit/transcript-view.ts +9 -0
  16. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  17. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  18. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  19. package/app/agentic/correlation-store.test.ts +99 -0
  20. package/app/agentic/correlation-store.ts +162 -0
  21. package/app/agentic/families/presence.family.test.ts +12 -0
  22. package/app/agentic/families/presence.family.ts +14 -0
  23. package/app/agentic/families/relay.family.test.ts +72 -0
  24. package/app/agentic/families/relay.family.ts +130 -1
  25. package/app/agentic/transcript-read.test.ts +55 -3
  26. package/app/agentic/transcript-read.ts +49 -9
  27. package/app/conformance.test.ts +2 -1
  28. package/app/conformance.ts +9 -3
  29. package/app/featureDelivery.test.ts +2 -1
  30. package/app/instanceTracking.ts +97 -0
  31. package/app/lineage.test.ts +2 -1
  32. package/app/lineage.ts +15 -2
  33. package/app/promotionPoll.test.ts +2 -1
  34. package/app/retro.test.ts +2 -1
  35. package/app/retro.ts +9 -2
  36. package/app/service.test.ts +15 -14
  37. package/app/service.ts +17 -24
  38. package/db/migrations/078_agentic_correlation.sql +32 -0
  39. package/e2e/convergence-loop.e2e.ts +41 -8
  40. package/openapi.yaml +26 -0
  41. package/operations/acknowledgeEpic.test.ts +2 -1
  42. package/operations/checkAbandon.test.ts +2 -1
  43. package/operations/getAgenticTranscript.ts +3 -2
  44. package/operations/getLineage.test.ts +2 -1
  45. package/operations/listAgenticTranscripts.ts +2 -1
  46. package/package.json +3 -3
  47. package/pages/cockpit/cockpit.css +65 -2
  48. package/pages/cockpit/mount.js +187 -16
  49. package/test/trackingViews.ts +50 -0
  50. package/test/worldDb.ts +2 -1
  51. 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 type { TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
9
- import { assertEquals } from "#test-assert";
10
- import { listTranscripts } from "./transcript-read.ts";
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: the jobKey is always decoded from a `job:<jobKey>`
40
- * stream id; the engine context (process instance / plan) is added only when the correlation registry
41
- * still holds the (live) job. Non-job streams yield an empty object.
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(stream: string, correlation: CorrelationRegistry | undefined): CorrelationFields {
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
  }
@@ -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
 
@@ -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
- data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
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.status);
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
+ }
@@ -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