@nanobpm/nano-workforce 0.52.0 → 0.54.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/CHANGELOG.md +14 -0
- package/app/agentic/families/blackboard.family.test.ts +189 -0
- package/app/agentic/families/blackboard.family.ts +69 -0
- package/app/agentic/families/relay.family.test.ts +402 -0
- package/app/agentic/families/relay.family.ts +331 -0
- package/app/blackboard.schema.test.ts +21 -0
- package/app/blackboard.test.ts +37 -69
- package/app/blackboard.ts +101 -124
- package/app/retro.test.ts +3 -1
- package/db/migrations/024_agentic_transcript.sql +43 -0
- package/db/migrations/025_agentic_blackboard.sql +39 -0
- package/operations/blackboard.test.ts +11 -27
- package/package.json +1 -1
- package/test/blackboardDb.ts +108 -0
- package/workers/retro-gather/worker.test.ts +2 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// nano-workforce — the relay ring + transcript store agentic family (ADR 0056, H3 / #146).
|
|
2
|
+
//
|
|
3
|
+
// This is a sibling slice of the agentic-visibility epic (#142). It plugs into the H0 (#143)
|
|
4
|
+
// family-registration SEAM (`../registry.ts`) as ONE NEW FILE and NOTHING ELSE — it never edits
|
|
5
|
+
// `main.ts`, `drainAndExit`, or any shared boot line. The auto-discovery loader (`../loader.ts`)
|
|
6
|
+
// finds it by the `*.family.ts` suffix and the seam mounts + tears it down.
|
|
7
|
+
//
|
|
8
|
+
// It composes two published primitives — it re-implements NEITHER:
|
|
9
|
+
// - `@nanobpm/agentic/relay` — the bounded replay ring, three-lane QoS scheduler
|
|
10
|
+
// (control > interactive > bulk), resume-from-offset, and incarnation/generation fencing,
|
|
11
|
+
// all inside {@link RelayHub}. We mount it on the hub's `registerFamilyHandler` seam.
|
|
12
|
+
// - `@nanobpm/agentic/transcript` — {@link TranscriptStore}, retention-by-lifecycle over the app's
|
|
13
|
+
// SQLite DataLayer. Ephemeral streams flush the ring to a durable transcript on job completion;
|
|
14
|
+
// long-lived streams retain chunks so a reconnecting consumer resumes-from-offset (reattach).
|
|
15
|
+
//
|
|
16
|
+
// Its DB schema ships as the reserved forward-only additive migration
|
|
17
|
+
// `db/migrations/024_agentic_transcript.sql` (H0 pre-allocated prefix 024 for H3), a byte-for-byte
|
|
18
|
+
// mirror of the package's canonical `TRANSCRIPT_SCHEMA_SQL`, drift-guarded by this slice's test.
|
|
19
|
+
//
|
|
20
|
+
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
21
|
+
// is untouched — the agentic channel is the only new conversation; advisory semantics preserved (the
|
|
22
|
+
// relay/transcript never hard-lock or gate a BPMN sequence flow).
|
|
23
|
+
import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
24
|
+
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
|
+
import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
|
|
26
|
+
import {
|
|
27
|
+
type SqliteDb,
|
|
28
|
+
type TranscriptLifecycle,
|
|
29
|
+
type TranscriptRing,
|
|
30
|
+
type TranscriptSlice,
|
|
31
|
+
TranscriptStore,
|
|
32
|
+
type TranscriptStoreOptions,
|
|
33
|
+
type TranscriptStream,
|
|
34
|
+
} from "@nanobpm/agentic/transcript";
|
|
35
|
+
import type { Logger } from "@nanobpm/urban";
|
|
36
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
37
|
+
|
|
38
|
+
/** The stable family name this slice registers under the seam (distinct from the wire family key). */
|
|
39
|
+
export const RELAY_FAMILY_NAME = "relay";
|
|
40
|
+
|
|
41
|
+
/** Read a property off an unknown value without an unsafe `as` cast (mirrors the loader's helper). */
|
|
42
|
+
function readProp(value: unknown, key: string): unknown {
|
|
43
|
+
if (!value || typeof value !== "object") return undefined;
|
|
44
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A resume-from-offset source with no retained chunks — used to flush/complete a stream that a
|
|
49
|
+
* producer opened logically but never wrote to, so its transcript is still stamped `completed`
|
|
50
|
+
* rather than left dangling `open`.
|
|
51
|
+
*/
|
|
52
|
+
const EMPTY_SOURCE: TranscriptRing = { since: () => ({ entries: [] }), nextOffset: 0 };
|
|
53
|
+
|
|
54
|
+
/** Per-stream bookkeeping the service keeps to drive lifecycle-aware persistence. */
|
|
55
|
+
interface StreamState {
|
|
56
|
+
/** Retention lifecycle: `ephemeral` (flush+complete on job end) vs `long-lived` (reattach). */
|
|
57
|
+
lifecycle: TranscriptLifecycle;
|
|
58
|
+
/** The connection id of the most recent producer, for disconnect-driven completion. */
|
|
59
|
+
producer?: string;
|
|
60
|
+
/** Set once an ephemeral stream has been flushed & completed (so it is not re-completed). */
|
|
61
|
+
completed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RelayTranscriptServiceOptions {
|
|
65
|
+
/** The app-tier hub; the service claims the `relay` family key via its registration seam. */
|
|
66
|
+
readonly hub: {
|
|
67
|
+
registerFamilyHandler(family: string, handler: (frame: Frame, conn: RelayConnectionCtx) => void): void;
|
|
68
|
+
};
|
|
69
|
+
/** The shared connection registry — its `has(id)` is the liveness source of truth. */
|
|
70
|
+
readonly registry: ConnectionRegistry;
|
|
71
|
+
/** The raw SQLite handle (the app DataLayer's `source().db`); absent → transcripts disabled. */
|
|
72
|
+
readonly db: SqliteDb | undefined;
|
|
73
|
+
/** A structured logger for lifecycle lines. */
|
|
74
|
+
readonly log: Logger;
|
|
75
|
+
/** Options forwarded to the underlying {@link RelayHub} (ring/bulk capacity, default credit). */
|
|
76
|
+
readonly relay?: RelayHubOptions;
|
|
77
|
+
/** Options forwarded to the {@link TranscriptStore} (retention window, injectable clock). */
|
|
78
|
+
readonly transcript?: TranscriptStoreOptions;
|
|
79
|
+
/**
|
|
80
|
+
* Apply the transcript DDL from the store on mount (idempotent `CREATE ... IF NOT EXISTS`).
|
|
81
|
+
* Default `true`: the boot migration is the canonical path, but this makes the service usable
|
|
82
|
+
* against a bare source too (and is harmless when the migration already ran).
|
|
83
|
+
*/
|
|
84
|
+
readonly ensureSchema?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The minimal per-connection surface the relay handler receives from the hub (a {@link RelayHub} `RelayConnection`). */
|
|
88
|
+
interface RelayConnectionCtx {
|
|
89
|
+
readonly id: string;
|
|
90
|
+
readonly registry: { has(id: string): boolean };
|
|
91
|
+
send(frame: Frame): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Composes the S5 relay ({@link RelayHub}) with the S6 transcript store ({@link TranscriptStore})
|
|
96
|
+
* and wires retention-by-lifecycle. It owns NO ring/scheduler/store logic of its own — it observes
|
|
97
|
+
* `produce` ownership so an ephemeral stream is flushed & completed when its producer disconnects,
|
|
98
|
+
* and exposes the completion/checkpoint/reattach/sweep surface H6 (#149) and the app drive.
|
|
99
|
+
*/
|
|
100
|
+
export class RelayTranscriptService {
|
|
101
|
+
/** The mounted relay hub (ring + QoS scheduler + incarnation fence). Exposed for inspection/tests. */
|
|
102
|
+
readonly relay: RelayHub;
|
|
103
|
+
/** The transcript store, or `undefined` when no DataLayer is mounted (relay still works, unpersisted). */
|
|
104
|
+
readonly store: TranscriptStore | undefined;
|
|
105
|
+
|
|
106
|
+
readonly #registry: ConnectionRegistry;
|
|
107
|
+
readonly #log: Logger;
|
|
108
|
+
readonly #streams = new Map<string, StreamState>();
|
|
109
|
+
|
|
110
|
+
constructor(options: RelayTranscriptServiceOptions) {
|
|
111
|
+
this.#registry = options.registry;
|
|
112
|
+
this.#log = options.log;
|
|
113
|
+
// Persistence is advisory: a store that can't be constructed or whose schema can't be applied
|
|
114
|
+
// (locked/permission-denied/unavailable SQLite) must NOT fail the family mount — fall back to
|
|
115
|
+
// running the relay unpersisted rather than tearing down the whole agentic channel.
|
|
116
|
+
let store: TranscriptStore | undefined;
|
|
117
|
+
if (options.db) {
|
|
118
|
+
try {
|
|
119
|
+
store = new TranscriptStore(options.db, options.transcript);
|
|
120
|
+
if (options.ensureSchema !== false) store.ensureSchema();
|
|
121
|
+
} catch (err) {
|
|
122
|
+
this.#log.warn("agentic transcript store unavailable — relay runs unpersisted", {
|
|
123
|
+
err: String(err),
|
|
124
|
+
});
|
|
125
|
+
store = undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.store = store;
|
|
129
|
+
|
|
130
|
+
this.relay = new RelayHub({
|
|
131
|
+
...options.relay,
|
|
132
|
+
onFenced: (stream, incarnation, current) => {
|
|
133
|
+
this.#log.warn("agentic relay fenced a stale producer", { stream, incarnation, current });
|
|
134
|
+
options.relay?.onFenced?.(stream, incarnation, current);
|
|
135
|
+
},
|
|
136
|
+
onError: (err, connectionId) => {
|
|
137
|
+
this.#log.warn("agentic relay message error", { connectionId, err: String(err) });
|
|
138
|
+
options.relay?.onError?.(err, connectionId);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Register the `relay` family ourselves (rather than via `registerRelayFamily`) so we can observe
|
|
143
|
+
// `produce` ownership before delegating to the hub — the hub's own routing still derives purely
|
|
144
|
+
// from this single registration, and a second `relay` registration is rejected by the seam.
|
|
145
|
+
options.hub.registerFamilyHandler(RELAY_FAMILY, (frame, conn) => this.#onFrame(frame, conn));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The tracked stream names (those a producer opened or that were declared). */
|
|
149
|
+
streams(): string[] {
|
|
150
|
+
return [...this.#streams.keys()];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Declare a stream's retention lifecycle before (or independently of) its first `produce`. The
|
|
155
|
+
* durable store records lifecycle write-once (first call wins there); in memory the latest call
|
|
156
|
+
* wins until the stream completes, which is how {@link checkpointStream} upgrades an as-yet
|
|
157
|
+
* ephemeral stream to `long-lived`. No-op once the stream has already been completed.
|
|
158
|
+
*/
|
|
159
|
+
declareLifecycle(stream: string, lifecycle: TranscriptLifecycle): void {
|
|
160
|
+
const state = this.#stateFor(stream);
|
|
161
|
+
if (!state.completed) state.lifecycle = lifecycle;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Complete a stream on job end: flush the relay ring (its whole retained window) to the durable
|
|
166
|
+
* transcript under the stream's lifecycle. For an `ephemeral` stream this stamps `completed_at`
|
|
167
|
+
* (so {@link reattach} then serves the durable transcript and {@link sweep} may later retire it);
|
|
168
|
+
* for a `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`. Idempotent:
|
|
169
|
+
* re-completing already-persisted offsets is a no-op. Returns the number of newly-persisted chunks.
|
|
170
|
+
*/
|
|
171
|
+
completeStream(stream: string): number {
|
|
172
|
+
if (!this.store) return 0;
|
|
173
|
+
const state = this.#stateFor(stream);
|
|
174
|
+
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
175
|
+
let flushed: number;
|
|
176
|
+
try {
|
|
177
|
+
flushed = this.store.flush(stream, source, state.lifecycle);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Persistence is advisory: a flush failure must not bubble into the hub's frame handler and
|
|
180
|
+
// take down unrelated streams. Log and leave the stream uncompleted so a later pass retries.
|
|
181
|
+
this.#log.warn("agentic relay stream flush failed — leaving stream uncompleted", {
|
|
182
|
+
stream,
|
|
183
|
+
lifecycle: state.lifecycle,
|
|
184
|
+
err: String(err),
|
|
185
|
+
});
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
if (state.lifecycle === "ephemeral") state.completed = true;
|
|
189
|
+
// Drop producer ownership so a later reconcile does not re-flush a completed stream.
|
|
190
|
+
state.producer = undefined;
|
|
191
|
+
this.#log.info("agentic relay stream flushed", {
|
|
192
|
+
stream,
|
|
193
|
+
lifecycle: state.lifecycle,
|
|
194
|
+
flushed,
|
|
195
|
+
completed: state.completed,
|
|
196
|
+
});
|
|
197
|
+
return flushed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Snapshot a long-lived stream's ring into the durable transcript without completing it — the
|
|
202
|
+
* checkpoint path a growing stream uses so a reconnecting consumer can {@link reattach} past the
|
|
203
|
+
* ring's resume window. Returns the number of newly-persisted chunks.
|
|
204
|
+
*/
|
|
205
|
+
checkpointStream(stream: string): number {
|
|
206
|
+
if (!this.store) return 0;
|
|
207
|
+
this.declareLifecycle(stream, "long-lived");
|
|
208
|
+
const source = this.relay.ring(stream) ?? EMPTY_SOURCE;
|
|
209
|
+
try {
|
|
210
|
+
return this.store.flush(stream, source, "long-lived");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// Advisory: a checkpoint failure keeps the relay usable — return 0, the stream stays open.
|
|
213
|
+
this.#log.warn("agentic relay checkpoint flush failed", { stream, err: String(err) });
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reattach a consumer from offset `from` (inclusive) against the durable transcript — mirrors the
|
|
220
|
+
* relay ring's `since` contract exactly, so a late/reconnecting consumer resumes identically
|
|
221
|
+
* whether from the live ring or the persisted transcript. Returns `undefined` with no store.
|
|
222
|
+
*/
|
|
223
|
+
reattach(stream: string, from: number): TranscriptSlice | undefined {
|
|
224
|
+
return this.store?.since(stream, from);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Retention sweep: retire completed-ephemeral transcripts past the retention window. */
|
|
228
|
+
sweep(now?: number): string[] {
|
|
229
|
+
const retired = this.store?.sweep(now) ?? [];
|
|
230
|
+
// Forget the in-memory state of every retired stream so `#streams` stays bounded (and
|
|
231
|
+
// `#reconcile`'s scan stays cheap) even after many ephemeral streams complete and age out.
|
|
232
|
+
for (const stream of retired) this.#streams.delete(stream);
|
|
233
|
+
return retired;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A stream's persisted transcript metadata, if any (lifecycle/status/offset window). */
|
|
237
|
+
transcriptOf(stream: string): TranscriptStream | undefined {
|
|
238
|
+
return this.store?.get(stream);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Complete every still-open ephemeral stream (e.g. on shutdown) so no in-flight terminal is lost,
|
|
243
|
+
* then forget all tracking. Long-lived streams are left for reattach and are not force-completed.
|
|
244
|
+
*/
|
|
245
|
+
teardown(): void {
|
|
246
|
+
for (const [stream, state] of this.#streams) {
|
|
247
|
+
if (state.lifecycle === "ephemeral" && !state.completed) this.completeStream(stream);
|
|
248
|
+
}
|
|
249
|
+
this.#streams.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, then delegate. */
|
|
253
|
+
#onFrame(frame: Frame, conn: RelayConnectionCtx): void {
|
|
254
|
+
this.#reconcile();
|
|
255
|
+
this.#observe(frame, conn);
|
|
256
|
+
this.relay.handle(frame, conn);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Record `produce` ownership so a producer disconnect can drive ephemeral completion. */
|
|
260
|
+
#observe(frame: Frame, conn: RelayConnectionCtx): void {
|
|
261
|
+
if (readProp(frame.payload, "op") !== "produce") return;
|
|
262
|
+
const stream = readProp(frame.payload, "stream");
|
|
263
|
+
if (typeof stream !== "string" || stream === "") return;
|
|
264
|
+
this.#stateFor(stream).producer = conn.id;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
|
|
269
|
+
* registry dropped it on close or liveness timeout). Lazy, like the relay hub's own dead-subscriber
|
|
270
|
+
* prune: it runs on each inbound frame, and shutdown covers the quiescent tail via {@link teardown}.
|
|
271
|
+
*/
|
|
272
|
+
#reconcile(): void {
|
|
273
|
+
for (const [stream, state] of this.#streams) {
|
|
274
|
+
if (state.completed || state.lifecycle !== "ephemeral") continue;
|
|
275
|
+
if (state.producer !== undefined && !this.#registry.has(state.producer)) {
|
|
276
|
+
this.completeStream(stream);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#stateFor(stream: string): StreamState {
|
|
282
|
+
let state = this.#streams.get(stream);
|
|
283
|
+
if (state === undefined) {
|
|
284
|
+
state = { lifecycle: "ephemeral", completed: false };
|
|
285
|
+
this.#streams.set(stream, state);
|
|
286
|
+
}
|
|
287
|
+
return state;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build the H3 relay family. Copy-of-the-seam pattern: it constructs the {@link RelayTranscriptService}
|
|
293
|
+
* in `mount` (threading the seam's hub/registry/DataLayer/log) and tears it down in `teardown`. The
|
|
294
|
+
* created service is exposed to `onMounted` so a driver (H6 correlation, tests) can reach the
|
|
295
|
+
* completion/reattach surface without re-mounting anything.
|
|
296
|
+
*/
|
|
297
|
+
export function createRelayFamily(options: {
|
|
298
|
+
readonly relay?: RelayHubOptions;
|
|
299
|
+
readonly transcript?: TranscriptStoreOptions;
|
|
300
|
+
readonly ensureSchema?: boolean;
|
|
301
|
+
/** Called with the live service once mounted, so a driver can drive completion/reattach. */
|
|
302
|
+
readonly onMounted?: (service: RelayTranscriptService) => void;
|
|
303
|
+
} = {}): AgenticFamily {
|
|
304
|
+
let service: RelayTranscriptService | undefined;
|
|
305
|
+
return {
|
|
306
|
+
name: RELAY_FAMILY_NAME,
|
|
307
|
+
mount(ctx: AgenticContext): void {
|
|
308
|
+
service = new RelayTranscriptService({
|
|
309
|
+
hub: ctx.hub,
|
|
310
|
+
registry: ctx.registry,
|
|
311
|
+
// The app's SQLite handle: the same store the advisory blackboard uses. Absent → relay runs
|
|
312
|
+
// unpersisted (still advisory-correct), rather than failing the whole channel boot.
|
|
313
|
+
db: ctx.data ? ctx.data.source().db : undefined,
|
|
314
|
+
log: ctx.log,
|
|
315
|
+
relay: options.relay,
|
|
316
|
+
transcript: options.transcript,
|
|
317
|
+
ensureSchema: options.ensureSchema,
|
|
318
|
+
});
|
|
319
|
+
options.onMounted?.(service);
|
|
320
|
+
},
|
|
321
|
+
teardown(): void {
|
|
322
|
+
service?.teardown();
|
|
323
|
+
service = undefined;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The discovered family instance (the loader picks up this `family` export). */
|
|
329
|
+
export const family: AgenticFamily = createRelayFamily();
|
|
330
|
+
|
|
331
|
+
export default family;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Schema-drift guard (#147). The `db/migrations/025_agentic_blackboard.sql` CREATE statements MUST be
|
|
2
|
+
// the canonical `BLACKBOARD_SCHEMA_SQL` verbatim — the exact DDL `@nanobpm/agentic/blackboard`'s
|
|
3
|
+
// `BlackboardStore.ensureSchema()` (and the agentic-channel family) apply. If the two ever drift, a
|
|
4
|
+
// board created by a migration on one host and by `ensureSchema()` on another would disagree — this
|
|
5
|
+
// test fails the build before that can ship.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { BLACKBOARD_SCHEMA_SQL } from "@nanobpm/agentic/blackboard";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
|
|
12
|
+
test("migration 025 CREATE statements equal BLACKBOARD_SCHEMA_SQL verbatim", () => {
|
|
13
|
+
const path = fileURLToPath(new URL("../db/migrations/025_agentic_blackboard.sql", import.meta.url));
|
|
14
|
+
const sql = readFileSync(path, "utf8");
|
|
15
|
+
const start = sql.indexOf("CREATE TABLE IF NOT EXISTS agentic_blackboard");
|
|
16
|
+
const end = sql.indexOf("(scope, id);");
|
|
17
|
+
assert(start !== -1, "migration 025 is missing the `CREATE TABLE IF NOT EXISTS agentic_blackboard` marker");
|
|
18
|
+
assert(end !== -1, "migration 025 is missing the `(scope, id);` index marker");
|
|
19
|
+
const createBlock = sql.slice(start, end + "(scope, id);".length).trim();
|
|
20
|
+
assertEquals(createBlock, BLACKBOARD_SCHEMA_SQL.trim());
|
|
21
|
+
});
|
package/app/blackboard.test.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
|
|
2
|
+
//
|
|
3
|
+
// H4 (#147) migrated the storage onto `@nanobpm/agentic/blackboard`'s shared `BlackboardStore`
|
|
4
|
+
// (table `agentic_blackboard`), reached over the app DataLayer's raw SQLite handle. These tests run
|
|
5
|
+
// the adapter against a REAL in-memory SQLite engine (see `test/blackboardDb.ts`), so the
|
|
6
|
+
// idempotency, conflict, and incremental-read behaviour is verified end-to-end, not against a mock.
|
|
2
7
|
import { test } from "node:test";
|
|
3
8
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
4
|
-
import
|
|
9
|
+
import { memBlackboardData } from "../test/blackboardDb.ts";
|
|
5
10
|
import {
|
|
6
11
|
appendEntry,
|
|
7
12
|
blackboardUrl,
|
|
@@ -10,41 +15,13 @@ import {
|
|
|
10
15
|
mintBlackboardToken,
|
|
11
16
|
normalizeKind,
|
|
12
17
|
planKeyForToken,
|
|
18
|
+
planKeyForTokenSync,
|
|
13
19
|
publicBaseUrl,
|
|
14
20
|
readBlackboard,
|
|
15
21
|
readBlackboardPage,
|
|
16
22
|
renderCoordinationBrief,
|
|
17
23
|
} from "./blackboard.ts";
|
|
18
24
|
|
|
19
|
-
// A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
|
|
20
|
-
// blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
|
|
21
|
-
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
22
|
-
const stores: Record<string, any[]> = {};
|
|
23
|
-
const seq: Record<string, number> = {};
|
|
24
|
-
function tbl(name: string, pk = "id") {
|
|
25
|
-
const rows = (stores[name] ??= [] as any[]);
|
|
26
|
-
return {
|
|
27
|
-
async insert(row: any) {
|
|
28
|
-
if (pk === "id") {
|
|
29
|
-
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
30
|
-
rows.push({ id, ...row });
|
|
31
|
-
return id;
|
|
32
|
-
}
|
|
33
|
-
rows.push({ ...row });
|
|
34
|
-
return row[pk];
|
|
35
|
-
},
|
|
36
|
-
async find(where: any = {}) {
|
|
37
|
-
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
38
|
-
},
|
|
39
|
-
async findOne(where: any = {}) {
|
|
40
|
-
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
45
|
-
return { data, stores };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
25
|
test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
49
26
|
const a = mintBlackboardToken();
|
|
50
27
|
const b = mintBlackboardToken();
|
|
@@ -106,16 +83,21 @@ test("renderCoordinationBrief: leads with a separator and teaches the protocol +
|
|
|
106
83
|
assertStringIncludes(brief, "Share what you learn");
|
|
107
84
|
});
|
|
108
85
|
|
|
109
|
-
test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
|
|
110
|
-
const { data } =
|
|
86
|
+
test("planKeyForToken: resolves a token to its plan, undefined otherwise (async + sync agree)", async () => {
|
|
87
|
+
const { data, db } = memBlackboardData();
|
|
111
88
|
await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
|
|
112
89
|
assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
|
|
113
90
|
assertEquals(await planKeyForToken(data, "nope"), undefined);
|
|
114
91
|
assertEquals(await planKeyForToken(data, ""), undefined);
|
|
92
|
+
// The sync resolver (used by the agentic channel's scopeOf) resolves the identical mapping, so the
|
|
93
|
+
// HTTP hook and the channel scope a plan's board to the same plan_key.
|
|
94
|
+
assertEquals(planKeyForTokenSync(db, "tok7"), "o/r#7");
|
|
95
|
+
assertEquals(planKeyForTokenSync(db, "nope"), undefined);
|
|
96
|
+
assertEquals(planKeyForTokenSync(db, ""), undefined);
|
|
115
97
|
});
|
|
116
98
|
|
|
117
99
|
test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
|
|
118
|
-
const { data } =
|
|
100
|
+
const { data } = memBlackboardData();
|
|
119
101
|
await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
|
|
120
102
|
await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
|
|
121
103
|
await appendEntry(data, "o/r#2", { body: "other plan" }); // must not leak across plans
|
|
@@ -128,14 +110,14 @@ test("appendEntry + readBlackboard: append, encode files, read back in write ord
|
|
|
128
110
|
});
|
|
129
111
|
|
|
130
112
|
test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
|
|
131
|
-
const { data } =
|
|
113
|
+
const { data } = memBlackboardData();
|
|
132
114
|
await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
|
|
133
115
|
const [e] = await readBlackboard(data, "p");
|
|
134
116
|
assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
|
|
135
117
|
});
|
|
136
118
|
|
|
137
119
|
test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
|
|
138
|
-
const { data } =
|
|
120
|
+
const { data } = memBlackboardData();
|
|
139
121
|
await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
|
|
140
122
|
const [e] = await readBlackboard(data, "p");
|
|
141
123
|
assertEquals(e.author_task, "system");
|
|
@@ -143,44 +125,30 @@ test("appendEntry: a missing author defaults to 'system' and kind is normalised"
|
|
|
143
125
|
});
|
|
144
126
|
|
|
145
127
|
test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
|
|
146
|
-
const { data,
|
|
128
|
+
const { data, db } = memBlackboardData();
|
|
147
129
|
const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
148
130
|
const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
149
131
|
assertEquals(first.inserted, true);
|
|
150
132
|
assertEquals(again.inserted, false, "second write with same dedupe_key is a no-op");
|
|
151
133
|
assertEquals(again.id, first.id, "returns the existing id");
|
|
152
|
-
|
|
134
|
+
const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["p"]);
|
|
135
|
+
assertEquals(n, 1, "exactly one row persisted");
|
|
153
136
|
});
|
|
154
137
|
|
|
155
|
-
test("appendEntry: a
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
preCheckDone = true;
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
return winner;
|
|
169
|
-
},
|
|
170
|
-
async insert() {
|
|
171
|
-
throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
|
|
172
|
-
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
173
|
-
});
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
const data = { table: () => table } as any as DataLayer;
|
|
177
|
-
const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
178
|
-
assertEquals(res.inserted, false, "a lost race is not a fresh insert");
|
|
179
|
-
assertEquals(res.id, 42, "returns the winning row's id");
|
|
138
|
+
test("appendEntry: a repeat dedupe_key collapses to the existing row instead of a fresh insert", async () => {
|
|
139
|
+
// The store's idempotent short-circuit (and its lost-UNIQUE-race recovery) means re-appending a
|
|
140
|
+
// fact under a stable dedupe_key returns the winning row as inserted:false rather than throwing —
|
|
141
|
+
// an engine job retry never duplicates or 500s.
|
|
142
|
+
const { data } = memBlackboardData();
|
|
143
|
+
const winner = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
144
|
+
assertEquals(winner.inserted, true);
|
|
145
|
+
const retry = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
146
|
+
assertEquals(retry.inserted, false, "a repeat is not a fresh insert");
|
|
147
|
+
assertEquals(retry.id, winner.id, "returns the winning row's id");
|
|
180
148
|
});
|
|
181
149
|
|
|
182
150
|
test("appendEntry: a blank body is rejected", async () => {
|
|
183
|
-
const { data } =
|
|
151
|
+
const { data } = memBlackboardData();
|
|
184
152
|
let threw = false;
|
|
185
153
|
try {
|
|
186
154
|
await appendEntry(data, "p", { body: " " });
|
|
@@ -191,7 +159,7 @@ test("appendEntry: a blank body is rejected", async () => {
|
|
|
191
159
|
});
|
|
192
160
|
|
|
193
161
|
test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
|
|
194
|
-
const { data } =
|
|
162
|
+
const { data } = memBlackboardData();
|
|
195
163
|
await appendEntry(data, "p", { body: "one" });
|
|
196
164
|
await appendEntry(data, "p", { body: "two" });
|
|
197
165
|
await appendEntry(data, "p", { body: "three" });
|
|
@@ -201,7 +169,7 @@ test("readBlackboard: since returns only newer entries (incremental poll)", asyn
|
|
|
201
169
|
});
|
|
202
170
|
|
|
203
171
|
test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
|
|
204
|
-
const { data } =
|
|
172
|
+
const { data } = memBlackboardData();
|
|
205
173
|
await appendEntry(data, "p", { body: "one" });
|
|
206
174
|
await appendEntry(data, "p", { body: "two" });
|
|
207
175
|
|
|
@@ -222,14 +190,14 @@ test("readBlackboardPage: cursor is the plan head and lets an agent poll to caug
|
|
|
222
190
|
});
|
|
223
191
|
|
|
224
192
|
test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
|
|
225
|
-
const { data } =
|
|
193
|
+
const { data } = memBlackboardData();
|
|
226
194
|
const page = await readBlackboardPage(data, "empty");
|
|
227
195
|
assertEquals(page.entries, []);
|
|
228
196
|
assertEquals(page.cursor, 0);
|
|
229
197
|
});
|
|
230
198
|
|
|
231
199
|
test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
|
|
232
|
-
const { data } =
|
|
200
|
+
const { data } = memBlackboardData();
|
|
233
201
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
|
|
234
202
|
|
|
235
203
|
const conflicts = await detectFileClaimConflicts(data, "p", {
|
|
@@ -242,7 +210,7 @@ test("detectFileClaimConflicts: a sibling's prior claim on the same file is surf
|
|
|
242
210
|
});
|
|
243
211
|
|
|
244
212
|
test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
|
|
245
|
-
const { data } =
|
|
213
|
+
const { data } = memBlackboardData();
|
|
246
214
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
|
|
247
215
|
await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
|
|
248
216
|
|
|
@@ -256,7 +224,7 @@ test("detectFileClaimConflicts: your own prior claim and non-file-claim entries
|
|
|
256
224
|
});
|
|
257
225
|
|
|
258
226
|
test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
|
|
259
|
-
const { data } =
|
|
227
|
+
const { data } = memBlackboardData();
|
|
260
228
|
const prior = await appendEntry(data, "p", {
|
|
261
229
|
author_task: "gap-2",
|
|
262
230
|
kind: "file-claim",
|