@nanobpm/agentic 0.1.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 (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,530 @@
1
+ /**
2
+ * The transcript store — S6's retention-by-lifecycle durable layer over the app
3
+ * DataLayer.
4
+ *
5
+ * The S5 relay ({@link https://npmjs.com/package/@nanobpm/agentic-relay | ReplayRing})
6
+ * keeps a *bounded* in-memory resume window per live stream. S6 layers a *durable*
7
+ * transcript on top, with retention differentiated by the stream's lifecycle:
8
+ *
9
+ * - **ephemeral** — a short run (e.g. one job). Its ring is flushed to a durable
10
+ * transcript on job completion ({@link TranscriptStore.flush}); the completed
11
+ * transcript is readable ({@link TranscriptStore.read}) and ages out only on an
12
+ * explicit retention sweep ({@link TranscriptStore.sweep}) — never while a
13
+ * consumer might still fetch it.
14
+ * - **long-lived** — a durable stream that outlives any single ring. Chunks are
15
+ * recorded incrementally ({@link TranscriptStore.record}); a reconnecting
16
+ * consumer reattaches from an offset ({@link TranscriptStore.since}); a rolling
17
+ * retention window is applied by {@link TranscriptStore.truncateBefore}, after
18
+ * which a reattach before the truncation point reports a `gap` (exactly the S5
19
+ * ring's resume-from-offset contract, now over durable storage).
20
+ *
21
+ * The store speaks only the tiny synchronous SQLite subset the runtime exposes
22
+ * ({@link SqliteDb}), so it works against any app DataLayer source without pulling
23
+ * in the whole runtime.
24
+ */
25
+ import { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE } from "./schema.ts";
26
+
27
+ /**
28
+ * The minimal synchronous SQLite handle the store needs — structurally the same
29
+ * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
30
+ * so the store depends on a shape, not on the runtime package. (Identical to the
31
+ * S2 presence store's `SqliteDb`.)
32
+ */
33
+ export interface SqliteDb {
34
+ /** Execute one or more statements with no result (DDL, migrations). */
35
+ exec(sql: string): void;
36
+ /** Run a parameterised statement, returning the changed-row count. */
37
+ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint };
38
+ /** Run a parameterised query, returning all rows as plain objects. */
39
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
40
+ }
41
+
42
+ /** A monotonic wall clock, injectable for deterministic tests. */
43
+ export interface Clock {
44
+ now(): number;
45
+ }
46
+
47
+ /** The default clock: `Date.now()`. */
48
+ export const systemClock: Clock = { now: () => Date.now() };
49
+
50
+ /**
51
+ * A stream's retention lifecycle. `ephemeral` transcripts are flushed once on job
52
+ * completion and retained until a retention sweep; `long-lived` transcripts grow
53
+ * incrementally and are bounded by a rolling offset window.
54
+ */
55
+ export type TranscriptLifecycle = "ephemeral" | "long-lived";
56
+
57
+ /** A stream's transcript status. */
58
+ export type TranscriptStatus = "open" | "completed";
59
+
60
+ /** A single durable transcript chunk and the offset it was assigned. */
61
+ export interface TranscriptChunk {
62
+ readonly offset: number;
63
+ readonly chunk: string;
64
+ }
65
+
66
+ /** Per-stream transcript metadata. */
67
+ export interface TranscriptStream {
68
+ readonly stream: string;
69
+ readonly lifecycle: TranscriptLifecycle;
70
+ readonly status: TranscriptStatus;
71
+ /** When the stream was first opened, ISO-8601. */
72
+ readonly createdAt: string;
73
+ /** When an ephemeral run was flushed & completed, ISO-8601 (undefined while open). */
74
+ readonly completedAt?: string;
75
+ /** The oldest retained offset, or undefined when the transcript is empty. */
76
+ readonly firstOffset?: number;
77
+ /** One past the highest offset ever recorded (the resume high-water mark). */
78
+ readonly nextOffset: number;
79
+ }
80
+
81
+ /**
82
+ * The result of a {@link TranscriptStore.since} reattach query — the same shape
83
+ * the S5 {@link ReplayRing.since} returns, now served from durable storage.
84
+ */
85
+ export interface TranscriptSlice {
86
+ /** The retained chunks with `offset >= from`, in offset order. */
87
+ readonly entries: readonly TranscriptChunk[];
88
+ /**
89
+ * `true` when `from` predates the oldest retained offset: chunks the consumer
90
+ * asked for were already dropped by retention (rolling window / expiry), so the
91
+ * replay is a best-effort resume, not gap-free from `from`.
92
+ */
93
+ readonly gap: boolean;
94
+ /** One past the highest recorded offset (where the live stream continues). */
95
+ readonly nextOffset: number;
96
+ }
97
+
98
+ /**
99
+ * The minimal resume-from-offset source a {@link TranscriptStore.flush} reads.
100
+ * The S5 {@link ReplayRing} satisfies this structurally (`since(0).entries` is the
101
+ * whole retained window; `nextOffset` is its high-water mark), so the store can
102
+ * flush a real relay ring without a compile dependency on the relay package.
103
+ */
104
+ export interface TranscriptRing {
105
+ since(from: number): { readonly entries: readonly TranscriptChunk[] };
106
+ readonly nextOffset: number;
107
+ }
108
+
109
+ export interface TranscriptStoreOptions {
110
+ /**
111
+ * How long a *completed ephemeral* transcript is retained after its
112
+ * `completed_at` before {@link TranscriptStore.sweep} may drop it, in ms.
113
+ * Default 86_400_000 (24h). Long-lived streams are never time-swept.
114
+ */
115
+ ephemeralRetentionMs?: number;
116
+ /** Injectable clock for deterministic tests. Default {@link systemClock}. */
117
+ clock?: Clock;
118
+ }
119
+
120
+ const DEFAULT_EPHEMERAL_RETENTION_MS = 86_400_000;
121
+
122
+ function isNonNegInt(value: unknown): value is number {
123
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
124
+ }
125
+
126
+ /**
127
+ * A recordable offset must be a non-negative safe integer that still leaves room
128
+ * for its successor: `#refreshWindow` derives `next_offset = maxOffset + 1`, so an
129
+ * offset of `Number.MAX_SAFE_INTEGER` would make `next_offset` a non-safe integer
130
+ * and break the resume contract (`since()` rejects non-safe integers). Cap one
131
+ * below the safe-integer ceiling.
132
+ */
133
+ function isRecordableOffset(value: unknown): value is number {
134
+ return isNonNegInt(value) && value < Number.MAX_SAFE_INTEGER;
135
+ }
136
+
137
+ /** The raw stream metadata row shape (snake_case columns) as read from SQLite. */
138
+ interface DbStreamRow {
139
+ stream: string;
140
+ lifecycle: string;
141
+ status: string;
142
+ created_at: string;
143
+ completed_at: string | null;
144
+ first_offset: number | null;
145
+ next_offset: number;
146
+ }
147
+
148
+ /** The raw chunk row shape as read from SQLite. */
149
+ interface DbChunkRow {
150
+ chunk_offset: number;
151
+ chunk: string;
152
+ }
153
+
154
+ function toLifecycle(value: string): TranscriptLifecycle {
155
+ if (value === "ephemeral" || value === "long-lived") return value;
156
+ throw new TranscriptCorruptionError(`invalid transcript lifecycle in DB: ${JSON.stringify(value)}`);
157
+ }
158
+
159
+ function toStatus(value: string): TranscriptStatus {
160
+ if (value === "open" || value === "completed") return value;
161
+ throw new TranscriptCorruptionError(`invalid transcript status in DB: ${JSON.stringify(value)}`);
162
+ }
163
+
164
+ function toStream(row: DbStreamRow): TranscriptStream {
165
+ const out: {
166
+ stream: string;
167
+ lifecycle: TranscriptLifecycle;
168
+ status: TranscriptStatus;
169
+ createdAt: string;
170
+ completedAt?: string;
171
+ firstOffset?: number;
172
+ nextOffset: number;
173
+ } = {
174
+ stream: row.stream,
175
+ lifecycle: toLifecycle(row.lifecycle),
176
+ status: toStatus(row.status),
177
+ createdAt: row.created_at,
178
+ nextOffset: row.next_offset,
179
+ };
180
+ if (row.completed_at !== null) out.completedAt = row.completed_at;
181
+ if (row.first_offset !== null) out.firstOffset = row.first_offset;
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Raised when a transcript row read back from storage holds a value outside its
187
+ * domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
188
+ * bad manual write. Fail fast rather than silently coercing to a default, which
189
+ * would mask the corruption and skew retention decisions.
190
+ */
191
+ export class TranscriptCorruptionError extends Error {
192
+ constructor(message: string) {
193
+ super(message);
194
+ this.name = "TranscriptCorruptionError";
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Raised when an operation targets a lifecycle it does not apply to (e.g.
200
+ * completing a `long-lived` stream, which by definition never completes).
201
+ */
202
+ export class TranscriptLifecycleError extends Error {
203
+ readonly stream: string;
204
+ constructor(stream: string, message: string) {
205
+ super(message);
206
+ this.name = "TranscriptLifecycleError";
207
+ this.stream = stream;
208
+ }
209
+ }
210
+
211
+ export class TranscriptStore {
212
+ readonly #db: SqliteDb;
213
+ readonly #ephemeralRetentionMs: number;
214
+ readonly #clock: Clock;
215
+
216
+ constructor(db: SqliteDb, options: TranscriptStoreOptions = {}) {
217
+ const retentionMs = options.ephemeralRetentionMs ?? DEFAULT_EPHEMERAL_RETENTION_MS;
218
+ if (!(typeof retentionMs === "number" && Number.isFinite(retentionMs) && retentionMs >= 0)) {
219
+ throw new RangeError(
220
+ `ephemeralRetentionMs must be a finite non-negative number, got ${retentionMs}`,
221
+ );
222
+ }
223
+ this.#db = db;
224
+ this.#ephemeralRetentionMs = retentionMs;
225
+ this.#clock = options.clock ?? systemClock;
226
+ }
227
+
228
+ /** The completed-ephemeral retention window in ms. */
229
+ get ephemeralRetentionMs(): number {
230
+ return this.#ephemeralRetentionMs;
231
+ }
232
+
233
+ /**
234
+ * Apply the canonical transcript DDL (idempotent). Callers that let the app
235
+ * DataLayer migration runner apply `db/migrations/002_agentic_transcript.sql`
236
+ * do not need this — but it is provided so the store is usable against a bare
237
+ * source too. The DDL is identical to the migration (drift-guarded).
238
+ */
239
+ ensureSchema(): void {
240
+ this.#db.exec(TRANSCRIPT_SCHEMA_SQL);
241
+ }
242
+
243
+ /**
244
+ * Open (or fetch) a stream's transcript with the given lifecycle. Idempotent:
245
+ * a first call stamps `created_at` and the lifecycle; later calls return the
246
+ * existing row unchanged (lifecycle is first-wins and never mutates). Returns
247
+ * the stored metadata row.
248
+ */
249
+ open(stream: string, lifecycle: TranscriptLifecycle): TranscriptStream {
250
+ this.#db.run(
251
+ `INSERT INTO ${TRANSCRIPT_STREAM_TABLE} (stream, lifecycle, status, created_at, next_offset)
252
+ VALUES (?, ?, 'open', ?, 0)
253
+ ON CONFLICT(stream) DO NOTHING`,
254
+ [stream, lifecycle, new Date(this.#clock.now()).toISOString()],
255
+ );
256
+ const row = this.get(stream);
257
+ if (row === undefined) {
258
+ throw new Error(`transcript stream row vanished immediately after open: ${stream}`);
259
+ }
260
+ return row;
261
+ }
262
+
263
+ /**
264
+ * Record chunks into a stream's durable transcript, idempotently. Each chunk is
265
+ * keyed `(stream, offset)`, so re-recording an already-stored offset (a retry, a
266
+ * re-flush, an overlapping reattach) is a no-op — never a duplicate. Auto-opens
267
+ * the stream with `lifecycle` (default `long-lived`) if it is not open yet; if the
268
+ * stream already exists under a different lifecycle this throws a
269
+ * {@link TranscriptLifecycleError} before writing anything (lifecycle is
270
+ * first-wins), so a mismatched flush cannot leave a partial write.
271
+ * The batch is atomic: if any entry has an invalid offset (or a write fails)
272
+ * partway through, the whole call rolls back — it records every chunk or none.
273
+ * Returns the number of newly-persisted chunks.
274
+ *
275
+ * This is the incremental path a long-lived stream uses; {@link flush} builds on
276
+ * it for the ephemeral completion path.
277
+ */
278
+ record(
279
+ stream: string,
280
+ entries: Iterable<TranscriptChunk>,
281
+ lifecycle: TranscriptLifecycle = "long-lived",
282
+ ): number {
283
+ const meta = this.open(stream, lifecycle);
284
+ if (meta.lifecycle !== lifecycle) {
285
+ throw new TranscriptLifecycleError(
286
+ stream,
287
+ `refusing to record into "${stream}" with lifecycle=${lifecycle}; the stream is ${meta.lifecycle} (lifecycle is first-wins)`,
288
+ );
289
+ }
290
+ const at = new Date(this.#clock.now()).toISOString();
291
+ // Make the batch write + window refresh all-or-nothing. If a later entry is
292
+ // invalid (or any write throws) partway through, the SAVEPOINT rolls back so
293
+ // record() never leaves partially-persisted chunks or stale first/next-offset
294
+ // metadata — it either records the whole batch or nothing.
295
+ return this.#atomic(() => {
296
+ let written = 0;
297
+ for (const entry of entries) {
298
+ if (!isRecordableOffset(entry.offset)) {
299
+ throw new RangeError(
300
+ `transcript offset must be a non-negative safe integer below Number.MAX_SAFE_INTEGER, got ${entry.offset}`,
301
+ );
302
+ }
303
+ const { changes } = this.#db.run(
304
+ `INSERT INTO ${TRANSCRIPT_CHUNK_TABLE} (stream, chunk_offset, chunk, appended_at)
305
+ VALUES (?, ?, ?, ?)
306
+ ON CONFLICT(stream, chunk_offset) DO NOTHING`,
307
+ [stream, entry.offset, entry.chunk, at],
308
+ );
309
+ written += changes;
310
+ }
311
+ this.#refreshWindow(stream);
312
+ return written;
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Flush a resume-from-offset source (an S5 {@link ReplayRing}) into a stream's
318
+ * durable transcript. Persists the source's entire retained window
319
+ * (`source.since(0)`) idempotently and advances the stream's high-water mark to
320
+ * `source.nextOffset` (so the recorded `nextOffset` reflects everything ever
321
+ * produced, even chunks the ring already evicted). Returns the number of
322
+ * newly-persisted chunks.
323
+ *
324
+ * For an `ephemeral` stream this is the job-completion flush: it also marks the
325
+ * transcript `completed` (stamping `completed_at`), after which {@link read}
326
+ * yields the durable transcript and {@link sweep} may later retire it. For a
327
+ * `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`.
328
+ */
329
+ flush(stream: string, source: TranscriptRing, lifecycle: TranscriptLifecycle): number {
330
+ const written = this.record(stream, source.since(0).entries, lifecycle);
331
+ // Advance the high-water mark to the source's nextOffset. The ring may have
332
+ // evicted early chunks, so its nextOffset can exceed maxStoredOffset+1; the
333
+ // recorded next_offset must reflect the true stream length for a later
334
+ // reattach's gap accounting to be correct.
335
+ if (isNonNegInt(source.nextOffset)) {
336
+ this.#raiseNextOffset(stream, source.nextOffset);
337
+ }
338
+ if (lifecycle === "ephemeral") {
339
+ this.#complete(stream);
340
+ }
341
+ return written;
342
+ }
343
+
344
+ /**
345
+ * Reattach a consumer from offset `from` (inclusive). Returns the retained
346
+ * chunks with `offset >= from`, the live `nextOffset`, and a `gap` flag when
347
+ * `from` predates the oldest retained offset (retention dropped chunks the
348
+ * consumer wanted). Mirrors the S5 {@link ReplayRing.since} contract exactly,
349
+ * so a reattach behaves identically whether it resumes from the live ring or
350
+ * the durable transcript.
351
+ */
352
+ since(stream: string, from: number): TranscriptSlice {
353
+ if (!isNonNegInt(from)) {
354
+ throw new RangeError(`since(from) requires a non-negative safe integer, got ${from}`);
355
+ }
356
+ const meta = this.get(stream);
357
+ if (meta === undefined) {
358
+ return { entries: [], gap: false, nextOffset: 0 };
359
+ }
360
+ const entries = this.#db
361
+ .all<DbChunkRow>(
362
+ `SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE}
363
+ WHERE stream = ? AND chunk_offset >= ? ORDER BY chunk_offset`,
364
+ [stream, from],
365
+ )
366
+ .map((r): TranscriptChunk => ({ offset: r.chunk_offset, chunk: r.chunk }));
367
+ // A gap means the consumer asked for an offset older than anything retained.
368
+ // Only meaningful while chunks remain: with an empty (fully-swept or freshly
369
+ // opened) transcript there is nothing to have "lost", so gap is false.
370
+ const gap = meta.firstOffset !== undefined && from < meta.firstOffset;
371
+ return { entries, gap, nextOffset: meta.nextOffset };
372
+ }
373
+
374
+ /** Read a stream's whole durable transcript in offset order. */
375
+ read(stream: string): TranscriptChunk[] {
376
+ return this.#db
377
+ .all<DbChunkRow>(
378
+ `SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? ORDER BY chunk_offset`,
379
+ [stream],
380
+ )
381
+ .map((r): TranscriptChunk => ({ offset: r.chunk_offset, chunk: r.chunk }));
382
+ }
383
+
384
+ /**
385
+ * Apply a rolling retention window to a long-lived stream: drop every chunk with
386
+ * `offset < before`. A subsequent {@link since} from an offset older than
387
+ * `before` reports a `gap`. Returns the number of chunks dropped. Refuses to
388
+ * truncate an `ephemeral` transcript (those are retained whole until swept) with
389
+ * a {@link TranscriptLifecycleError}.
390
+ */
391
+ truncateBefore(stream: string, before: number): number {
392
+ if (!isNonNegInt(before)) {
393
+ throw new RangeError(`truncateBefore(before) requires a non-negative safe integer, got ${before}`);
394
+ }
395
+ const meta = this.get(stream);
396
+ if (meta === undefined) return 0;
397
+ if (meta.lifecycle === "ephemeral") {
398
+ throw new TranscriptLifecycleError(
399
+ stream,
400
+ `refusing to truncate ephemeral transcript "${stream}"; ephemeral runs are retained whole until sweep()`,
401
+ );
402
+ }
403
+ const { changes } = this.#db.run(
404
+ `DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? AND chunk_offset < ?`,
405
+ [stream, before],
406
+ );
407
+ if (changes > 0) this.#refreshWindow(stream);
408
+ return changes;
409
+ }
410
+
411
+ /**
412
+ * Retention sweep for completed ephemeral transcripts: drop every stream whose
413
+ * `status = 'completed'` and whose `completed_at` is older than the retention
414
+ * window, along with its chunks. Long-lived streams are never time-swept (they
415
+ * are bounded by {@link truncateBefore} instead). Returns the removed stream ids.
416
+ *
417
+ * The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
418
+ * sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
419
+ * back, so the DB is never left partially swept and the returned list always
420
+ * matches what was actually deleted.
421
+ */
422
+ sweep(now: number = this.#clock.now()): string[] {
423
+ const cutoffIso = new Date(now - this.#ephemeralRetentionMs).toISOString();
424
+ return this.#atomic(() => {
425
+ const removed = this.#db
426
+ .all<{ stream: string }>(
427
+ `SELECT stream FROM ${TRANSCRIPT_STREAM_TABLE}
428
+ WHERE lifecycle = 'ephemeral' AND status = 'completed'
429
+ AND completed_at IS NOT NULL AND completed_at < ?`,
430
+ [cutoffIso],
431
+ )
432
+ .map((r) => r.stream);
433
+ for (const stream of removed) {
434
+ this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream]);
435
+ this.#db.run(`DELETE FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
436
+ }
437
+ return removed;
438
+ });
439
+ }
440
+
441
+ /** Look up a single stream's transcript metadata. */
442
+ get(stream: string): TranscriptStream | undefined {
443
+ const rows = this.#db.all<DbStreamRow>(
444
+ `SELECT * FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`,
445
+ [stream],
446
+ );
447
+ const row = rows[0];
448
+ return row === undefined ? undefined : toStream(row);
449
+ }
450
+
451
+ /** Every stream's metadata, ordered by first open then stream id. */
452
+ list(): TranscriptStream[] {
453
+ return this.#db
454
+ .all<DbStreamRow>(`SELECT * FROM ${TRANSCRIPT_STREAM_TABLE} ORDER BY created_at, stream`)
455
+ .map(toStream);
456
+ }
457
+
458
+ /** Number of tracked streams. */
459
+ count(): number {
460
+ const rows = this.#db.all<{ n: number }>(`SELECT COUNT(*) AS n FROM ${TRANSCRIPT_STREAM_TABLE}`);
461
+ return rows[0]?.n ?? 0;
462
+ }
463
+
464
+ /** Mark an ephemeral stream completed (idempotent), stamping `completed_at`. */
465
+ #complete(stream: string): void {
466
+ const meta = this.get(stream);
467
+ if (meta === undefined) return;
468
+ if (meta.lifecycle !== "ephemeral") {
469
+ throw new TranscriptLifecycleError(
470
+ stream,
471
+ `refusing to complete non-ephemeral transcript "${stream}" (lifecycle=${meta.lifecycle})`,
472
+ );
473
+ }
474
+ if (meta.status === "completed") return;
475
+ this.#db.run(
476
+ `UPDATE ${TRANSCRIPT_STREAM_TABLE} SET status = 'completed', completed_at = ? WHERE stream = ?`,
477
+ [new Date(this.#clock.now()).toISOString(), stream],
478
+ );
479
+ }
480
+
481
+ /**
482
+ * Recompute a stream's retained offset window (`first_offset`, and `next_offset`
483
+ * raised to `max(chunk_offset)+1`) from the chunks actually stored. `next_offset`
484
+ * is monotonic — it is never lowered below its recorded high-water mark, so a
485
+ * truncation that drops the tail (there is none — truncation drops the head) or
486
+ * a re-record cannot rewind the resume point.
487
+ */
488
+ #refreshWindow(stream: string): void {
489
+ const agg = this.#db.all<{ mn: number | null; mx: number | null }>(
490
+ `SELECT MIN(chunk_offset) AS mn, MAX(chunk_offset) AS mx FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`,
491
+ [stream],
492
+ )[0];
493
+ const minOffset = agg?.mn ?? null;
494
+ const maxOffset = agg?.mx ?? null;
495
+ const candidateNext = maxOffset === null ? 0 : maxOffset + 1;
496
+ this.#db.run(
497
+ `UPDATE ${TRANSCRIPT_STREAM_TABLE}
498
+ SET first_offset = ?, next_offset = MAX(next_offset, ?)
499
+ WHERE stream = ?`,
500
+ [minOffset, candidateNext, stream],
501
+ );
502
+ }
503
+
504
+ /** Raise a stream's `next_offset` high-water mark (never lowers it). */
505
+ #raiseNextOffset(stream: string, nextOffset: number): void {
506
+ this.#db.run(
507
+ `UPDATE ${TRANSCRIPT_STREAM_TABLE} SET next_offset = MAX(next_offset, ?) WHERE stream = ?`,
508
+ [nextOffset, stream],
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Run `body` inside a SQLite SAVEPOINT so its writes are atomic: on any throw
514
+ * the savepoint is rolled back (nothing it wrote persists) and the error is
515
+ * re-raised; on success it is released. SAVEPOINTs nest, so this is safe
516
+ * whether or not an outer transaction is already open.
517
+ */
518
+ #atomic<T>(body: () => T): T {
519
+ this.#db.exec("SAVEPOINT nano_atomic");
520
+ try {
521
+ const result = body();
522
+ this.#db.exec("RELEASE SAVEPOINT nano_atomic");
523
+ return result;
524
+ } catch (err) {
525
+ this.#db.exec("ROLLBACK TO SAVEPOINT nano_atomic");
526
+ this.#db.exec("RELEASE SAVEPOINT nano_atomic");
527
+ throw err;
528
+ }
529
+ }
530
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Test-only helper: a {@link SqliteDb} backed by an in-memory `node:sqlite`
3
+ * database, mirroring the Node host adapter's `wrapNodeSqlite` (and the identical
4
+ * helper in @nanobpm/agentic-presence). Kept out of the published build (see
5
+ * `tsconfig.build.json` exclude) — it exists solely so the store tests exercise
6
+ * the store against a real SQLite engine, not a mock.
7
+ */
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import type { SqliteDb } from "./store.ts";
10
+
11
+ export interface TestDb extends SqliteDb {
12
+ close(): void;
13
+ }
14
+
15
+ function toParams(params: unknown[]): (string | number | bigint | null | Uint8Array)[] {
16
+ return params.map((p) => {
17
+ if (p === null) return null;
18
+ if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
19
+ return p;
20
+ }
21
+ if (typeof p === "boolean") return p ? 1 : 0;
22
+ // Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
23
+ // `undefined`) throw rather than coerce, so a test can never pass on a
24
+ // parameter production would reject.
25
+ throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
26
+ });
27
+ }
28
+
29
+ export function openTestDb(): TestDb {
30
+ const db = new DatabaseSync(":memory:");
31
+ return {
32
+ exec: (sql) => db.exec(sql),
33
+ run: (sql, params = []) => {
34
+ const stmt = db.prepare(sql);
35
+ const r = stmt.run(...toParams(params));
36
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
37
+ },
38
+ all: <T>(sql: string, params: unknown[] = []): T[] => {
39
+ const stmt = db.prepare(sql);
40
+ // Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
41
+ // biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
42
+ return stmt.all(...toParams(params)) as T[];
43
+ },
44
+ close: () => db.close(),
45
+ };
46
+ }
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { validateVocabDocument } from "../protocol/index.ts";
4
+ import { CORE_VOCAB, CORE_VOCAB_VERSION } from "./core-vocab.ts";
5
+
6
+ test("the core vocabulary is a valid S0 vocab artifact", () => {
7
+ const result = validateVocabDocument(CORE_VOCAB);
8
+ assert.equal(result.ok, true);
9
+ });
10
+
11
+ test("the core vocabulary declares the opinionated networks and the bare decide role", () => {
12
+ assert.equal(CORE_VOCAB.version, CORE_VOCAB_VERSION);
13
+ const networks = Object.keys(CORE_VOCAB.networks).sort();
14
+ assert.deepEqual(networks, ["ci", "decide", "implementation", "planning", "qa"]);
15
+ });
16
+
17
+ test("review roles opt into strict distinct-family seating with red/blue seats", () => {
18
+ for (const network of ["planning", "qa", "implementation"] as const) {
19
+ const reviewer = CORE_VOCAB.networks[network]?.roles?.reviewer;
20
+ assert.ok(reviewer !== undefined, `${network}.reviewer exists`);
21
+ assert.equal(reviewer?.seatsDistinctFamily, true);
22
+ assert.deepEqual(reviewer?.seats, ["red", "blue"]);
23
+ }
24
+ });
25
+
26
+ test("the core vocabulary is deep-frozen against mutation", () => {
27
+ assert.equal(Object.isFrozen(CORE_VOCAB), true);
28
+ assert.equal(Object.isFrozen(CORE_VOCAB.networks), true);
29
+ assert.equal(Object.isFrozen(CORE_VOCAB.networks.planning), true);
30
+ assert.throws(() => {
31
+ // Attempting to mutate the frozen artifact throws in strict mode.
32
+ Object.assign(CORE_VOCAB.networks.planning.roles ?? {}, { hacked: {} });
33
+ });
34
+ });