@nanobpm/nano-workforce 0.188.1 → 0.189.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.
@@ -13,6 +13,12 @@
13
13
  import type { Capability } from "@nanobpm/agentic/protocol";
14
14
  import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
15
15
  import { DurableResumeRegistry } from "../app/durableResume.ts";
16
+ import {
17
+ HarnessProtocolRegistry,
18
+ isStaleProtocol,
19
+ minHarnessProtocol,
20
+ staleHarnessPolicy,
21
+ } from "../app/harnessProtocol.ts";
16
22
  import { envVar } from "../app/version.ts";
17
23
  import type { EnrolResult } from "../nano-generated/api-io.d.ts";
18
24
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -73,6 +79,22 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
73
79
  app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
74
80
  return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
75
81
  }
82
+ // The harness-protocol enrolment attribute (issue #802) — a non-negative integer the harness
83
+ // advertises declaring which machine-readable artifacts it emits (AgentInstance, transcript flush,
84
+ // result envelope). A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the
85
+ // type here: a non-integer / negative value would corrupt the persisted staleness gate.
86
+ if (
87
+ body.harnessProtocol !== undefined &&
88
+ (typeof body.harnessProtocol !== "number" ||
89
+ !Number.isInteger(body.harnessProtocol) ||
90
+ body.harnessProtocol < 0)
91
+ ) {
92
+ app.log.warn("enrolAgenticWorker rejected: non-integer harnessProtocol");
93
+ return {
94
+ status: 400,
95
+ body: { error: "`harnessProtocol` must be a non-negative integer when provided" },
96
+ };
97
+ }
76
98
 
77
99
  // Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
78
100
  // may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
@@ -102,19 +124,57 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
102
124
  }
103
125
  }
104
126
 
127
+ // Harness-protocol enrolment gate (issue #802): record the advertised protocol per instance so the
128
+ // app can expose it in getAgenticSupply / the registry and gate on it. Recorded even on a downgrade
129
+ // (a re-enrol WITHOUT a version clears a stale-healthy value to absent → stale). Like durable-resume
130
+ // it needs a non-blank `instance` and is best-effort — a registry write hiccup must not fail enrol.
131
+ if (app.data && instanceKey) {
132
+ try {
133
+ await new HarnessProtocolRegistry(app.data).recordEnrolment(instanceKey, body.harnessProtocol);
134
+ } catch (err) {
135
+ app.log.warn("enrolAgenticWorker: harness-protocol record failed", { instance: instanceKey, err: String(err) });
136
+ }
137
+ }
138
+
139
+ // Derive staleness from the just-advertised protocol against the configured minimum (absent version =
140
+ // stale, the #802 signature). Under the `refuse` policy a stale harness is handed an EMPTY SERVE set
141
+ // so it wins no job leases — a REGISTER→SERVE gate, never an engine/job-protocol change. Under the
142
+ // default `flag` policy the SERVE set is unchanged (no routing regression); the worker is only
143
+ // flagged so the cockpit can surface it for drain.
144
+ const harnessStale = isStaleProtocol(body.harnessProtocol, minHarnessProtocol());
145
+ const refuseRouting = harnessStale && staleHarnessPolicy() === "refuse";
146
+ if (refuseRouting) {
147
+ app.log.warn("enrolAgenticWorker: refusing routing for a stale harness", {
148
+ instance: body.instance,
149
+ harnessProtocol: body.harnessProtocol,
150
+ minHarnessProtocol: minHarnessProtocol(),
151
+ });
152
+ }
153
+
105
154
  const result: EnrolResult = {
106
- serve: [...resolved.serve],
107
- roles: resolved.roles.map((role) => {
108
- const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
109
- if (role.weight !== undefined) out.weight = role.weight;
110
- return out;
111
- }),
155
+ serve: refuseRouting ? [] : [...resolved.serve],
156
+ roles: refuseRouting
157
+ ? []
158
+ : resolved.roles.map((role) => {
159
+ const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
160
+ if (role.weight !== undefined) out.weight = role.weight;
161
+ return out;
162
+ }),
112
163
  demandVersion: resolved.demandVersion,
113
164
  leaseTtl: resolved.leaseTtl,
165
+ // Always surface the staleness verdict so the caller (and the cockpit) can see a stale harness even
166
+ // when it advertised no version at all.
167
+ harnessStale,
114
168
  };
115
169
  if (body.instance !== undefined) result.instance = body.instance;
116
170
  if (body.durableResume !== undefined) result.durableResume = body.durableResume;
171
+ if (body.harnessProtocol !== undefined) result.harnessProtocol = body.harnessProtocol;
117
172
 
118
- app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
173
+ app.log.info("agentic enrol resolved", {
174
+ instance: body.instance,
175
+ serve: result.serve,
176
+ family: capability.family,
177
+ harnessStale,
178
+ });
119
179
  return { status: 200, body: result };
120
180
  });
@@ -20,6 +20,6 @@ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
20
20
  app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
- const report = await computeRegistryReport(app.log);
23
+ const report = await computeRegistryReport(app.log, app.data);
24
24
  return { status: 200, body: toWireReport(report) };
25
25
  });
@@ -5,6 +5,7 @@
5
5
  // by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
6
6
  // exactly as the presence family is exercised, so the singleton the operation reads is the live one.
7
7
  import { DatabaseSync } from "node:sqlite";
8
+ import { readFileSync } from "node:fs";
8
9
  import { test } from "node:test";
9
10
  import { AgenticHub } from "@nanobpm/agentic/channel";
10
11
  import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
@@ -39,6 +40,19 @@ function memData(db: SqliteDb): DataLayer {
39
40
  return { source: () => ({ db }) } as unknown as DataLayer;
40
41
  }
41
42
 
43
+ /** Wrap an existing DatabaseSync as a SqliteDb (so the presence store and a raw insert share one db). */
44
+ function memSqliteOver(db: DatabaseSync): SqliteDb {
45
+ return {
46
+ exec: (sql) => db.exec(sql),
47
+ run: (sql, params = []) => {
48
+ const r = db.prepare(sql).run(...(params as never[]));
49
+ return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
50
+ },
51
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
52
+ db.prepare(sql).all(...(params as never[])) as T[],
53
+ };
54
+ }
55
+
42
56
  function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
43
57
  let onConnection: ((conn: ChannelConnection) => void) | undefined;
44
58
  const transport: ChannelTransport = {
@@ -259,3 +273,69 @@ test("#738 drift: the supply advertises the producer's instance-scoped stream id
259
273
  await hub.close();
260
274
  }
261
275
  });
276
+
277
+ // Harness staleness (issue #802): the supply report flags a worker whose harness protocol is below the
278
+ // minimum / absent, joined by instance from the app's harness-protocol registry.
279
+ test("#802: flags harnessStale per worker, joining the harness-protocol registry by instance", async () => {
280
+ const raw = new DatabaseSync(":memory:");
281
+ raw.exec("PRAGMA foreign_keys = ON;");
282
+ raw.exec(readFileSync(new URL("../db/migrations/107_worker_harness_protocol.sql", import.meta.url), "utf8"));
283
+ const now = new Date().toISOString();
284
+ // wk-a advertised a healthy protocol (>= min 1); wk-a's presence row is minted below.
285
+ raw.prepare("INSERT INTO worker_harness_protocol (instance, harness_protocol, updated_at) VALUES (?, ?, ?)").run("wk-a", 2, now);
286
+
287
+ // A data layer providing BOTH the presence `source().db` handle and the RAD `table()` surface over
288
+ // the SAME db, so the presence family and the harness registry read one store.
289
+ const sqlite = memSqliteOver(raw);
290
+ const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
291
+ const table = (name: string, pk = "id") => ({
292
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
293
+ async find(where: any = {}): Promise<any[]> {
294
+ const keys = Object.keys(where);
295
+ const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
296
+ return raw.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => where[k])) as any[];
297
+ },
298
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
299
+ async findOne(where: any = {}): Promise<any> {
300
+ return (await this.find(where))[0];
301
+ },
302
+ _pk: pk,
303
+ });
304
+ const data = {
305
+ source: () => ({ db: sqlite }),
306
+ table,
307
+ // The raw-SQL surface the harness registry's bounded `WHERE instance IN (…)` read binds to, over
308
+ // the SAME db as `table`/presence.
309
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
310
+ open: () => ({ query: async (sql: string, params: any[] = []) => raw.prepare(sql).all(...params) as any[] }),
311
+ } as unknown as DataLayer;
312
+
313
+ const transport = memTransport();
314
+ const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
315
+ await family.mount({ hub, registry: hub.registry, transport: transport.transport as never, data, log: noopLog() });
316
+ const healthyConn = fakeConn("c1", "leafA");
317
+ transport.connect(healthyConn.conn);
318
+ await flush();
319
+ healthyConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: {} } });
320
+ const staleConn = fakeConn("c2", "leafA");
321
+ transport.connect(staleConn.conn);
322
+ await flush();
323
+ // wk-b registers but has NO harness-protocol row → absent = stale.
324
+ staleConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-b", capability: {} } });
325
+ await flush();
326
+
327
+ const withData = { log: noopLog(), data } as unknown as AppApi;
328
+ try {
329
+ const res = (await handler(input(), withData)) as { status: number; body: { workers: Array<Record<string, unknown>> } };
330
+ assertEquals(res.status, 200);
331
+ const byInstance = new Map(res.body.workers.map((w) => [w.instance, w]));
332
+ assertEquals(byInstance.get("wk-a")?.harnessStale, false, "healthy protocol is not stale");
333
+ assertEquals(byInstance.get("wk-a")?.harnessProtocol, 2);
334
+ assertEquals(byInstance.get("wk-b")?.harnessStale, true, "no recorded protocol = stale");
335
+ assertEquals("harnessProtocol" in (byInstance.get("wk-b") ?? {}), false, "no protocol echoed for a stale worker");
336
+ } finally {
337
+ family.teardown?.();
338
+ await hub.close();
339
+ raw.close();
340
+ }
341
+ });
@@ -23,6 +23,7 @@
23
23
  import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
24
24
  import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
25
25
  import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
26
+ import { assessWorkers, type HarnessAssessment } from "../app/harnessProtocol.ts";
26
27
  import { envVar } from "../app/version.ts";
27
28
  import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
28
29
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -36,7 +37,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
36
37
  // (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
37
38
  // it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
38
39
  // (the exact stream the producer writes) even before any transcript lands.
39
- function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
40
+ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined, harness: HarnessAssessment | undefined): AgenticSupplyWorker {
40
41
  const out: AgenticSupplyWorker = {
41
42
  instance: w.instance,
42
43
  identity: w.identity,
@@ -44,9 +45,13 @@ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSu
44
45
  jobKeys: [...w.jobKeys],
45
46
  live: w.live,
46
47
  staleMs: w.staleMs,
48
+ // Harness staleness (issue #802) — an absent registry entry / unmounted data layer reads as stale
49
+ // (fail loud). Distinct from the liveness `staleMs` heartbeat grade above.
50
+ harnessStale: harness?.stale ?? true,
47
51
  };
48
52
  if (w.family !== undefined) out.family = w.family;
49
53
  if (w.host !== undefined) out.host = w.host;
54
+ if (harness?.harnessProtocol !== undefined) out.harnessProtocol = harness.harnessProtocol;
50
55
  return out;
51
56
  }
52
57
 
@@ -82,11 +87,18 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
82
87
  // process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
83
88
  const correlation = currentCorrelation();
84
89
  const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
90
+ // Harness-staleness (issue #802): assess every visible worker's advertised protocol against the
91
+ // configured minimum — the ONE canonical staleness derivation (no second heuristic). Best-effort:
92
+ // an unmounted data layer / read failure reads as stale (fail loud).
93
+ const harness = await assessWorkers(app.data, snapshot.workers.map((w) => w.instance));
85
94
  const report: AgenticSupplyReport = {
86
95
  count: snapshot.count,
87
96
  generatedAt: new Date().toISOString(),
88
- workers: snapshot.workers.map((w) => toWorker(w, claims)),
89
- leaves: snapshot.leaves.map((leaf) => ({ token: leaf.token, workers: leaf.workers.map((w) => toWorker(w, claims)) })),
97
+ workers: snapshot.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
98
+ leaves: snapshot.leaves.map((leaf) => ({
99
+ token: leaf.token,
100
+ workers: leaf.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
101
+ })),
90
102
  correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
91
103
  };
92
104
  return { status: 200, body: report };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.188.1",
3
+ "version": "0.189.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",
@@ -84,6 +84,10 @@ function workerView(worker, staleAfterMs, byJobKey) {
84
84
  correlations,
85
85
  liveness: liveness(worker, staleAfterMs),
86
86
  staleMs: worker.staleMs,
87
+ // Fail loud: default a missing harness verdict to STALE (mirrors the typed cockpit view and the
88
+ // server's fail-loud assessment) so an older/cached response can't hide a stale/unknown worker.
89
+ harnessStale: worker.harnessStale ?? true,
90
+ ...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}),
87
91
  };
88
92
  }
89
93
 
@@ -141,6 +145,7 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
141
145
  row.setAttribute("data-worker", worker.instance);
142
146
  row.setAttribute("data-liveness", worker.liveness);
143
147
  row.setAttribute("data-stream", worker.stream);
148
+ row.setAttribute("data-harness-stale", String(worker.harnessStale));
144
149
 
145
150
  const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
146
151
  nameCell.appendChild(dot(doc, worker.liveness));
@@ -162,6 +167,19 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
162
167
  if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
163
168
  nameCell.appendChild(drill);
164
169
  }
170
+ // A stale harness silently swallows machine-readable artifacts (issue #802) — surface it as a
171
+ // distinct badge (mirrors app/agentic/cockpit/supply-render.ts).
172
+ if (worker.harnessStale) {
173
+ const badge = el(
174
+ doc,
175
+ "span",
176
+ "cockpit-supply-harness-stale",
177
+ worker.harnessProtocol === undefined ? "stale harness" : `stale harness (v${worker.harnessProtocol})`,
178
+ );
179
+ badge.setAttribute("data-harness-stale", "true");
180
+ badge.setAttribute("title", "Harness protocol below the configured minimum (or none advertised); jobs may dead-end.");
181
+ nameCell.appendChild(badge);
182
+ }
165
183
  row.appendChild(nameCell);
166
184
 
167
185
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
package/test/worldDb.ts CHANGED
@@ -69,12 +69,18 @@ function gateway(db: DatabaseSync, name: string, pk: string) {
69
69
  * that decorates `table` on the returned source sees its decoration inside the transaction too. */
70
70
  type MemDataSource = {
71
71
  table: (name: string, pk?: string) => ReturnType<typeof gateway>;
72
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
72
73
  tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
73
74
  };
74
75
 
75
76
  function openDataSource(db: DatabaseSync): MemDataSource {
76
77
  const ds: MemDataSource = {
77
78
  table: (name, pk = "id") => gateway(db, name, pk),
79
+ // The row-returning raw-SQL surface (mirrors the runtime `DataSource.query`) so tests exercise a
80
+ // handler's bounded `WHERE … IN (…)` reads against genuine SQLite rather than a mock.
81
+ async query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
82
+ return db.prepare(sql).all(...params.map(coerce)) as T[];
83
+ },
78
84
  async tx(fn) {
79
85
  db.exec("BEGIN");
80
86
  try {