@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.
- package/README.md +22 -0
- package/dist/blackboard/family.d.ts +40 -0
- package/dist/blackboard/family.js +151 -0
- package/dist/blackboard/index.d.ts +21 -0
- package/dist/blackboard/index.js +19 -0
- package/dist/blackboard/schema.d.ts +30 -0
- package/dist/blackboard/schema.js +42 -0
- package/dist/blackboard/store.d.ts +138 -0
- package/dist/blackboard/store.js +216 -0
- package/dist/blackboard/test-db.d.ts +5 -0
- package/dist/blackboard/test-db.js +42 -0
- package/dist/channel/auth.d.ts +41 -0
- package/dist/channel/auth.js +67 -0
- package/dist/channel/clock.d.ts +11 -0
- package/dist/channel/clock.js +4 -0
- package/dist/channel/connection.d.ts +75 -0
- package/dist/channel/connection.js +14 -0
- package/dist/channel/dispatch.d.ts +46 -0
- package/dist/channel/dispatch.js +86 -0
- package/dist/channel/hub.d.ts +78 -0
- package/dist/channel/hub.js +157 -0
- package/dist/channel/index.d.ts +27 -0
- package/dist/channel/index.js +20 -0
- package/dist/channel/registry.d.ts +68 -0
- package/dist/channel/registry.js +84 -0
- package/dist/channel/ws-transport.d.ts +23 -0
- package/dist/channel/ws-transport.js +178 -0
- package/dist/cockpit/boot.d.ts +68 -0
- package/dist/cockpit/boot.js +202 -0
- package/dist/cockpit/fake-dom.d.ts +37 -0
- package/dist/cockpit/fake-dom.js +73 -0
- package/dist/cockpit/index.d.ts +27 -0
- package/dist/cockpit/index.js +27 -0
- package/dist/cockpit/relay-client.d.ts +52 -0
- package/dist/cockpit/relay-client.js +192 -0
- package/dist/cockpit/render.d.ts +58 -0
- package/dist/cockpit/render.js +122 -0
- package/dist/cockpit/terminal-session.d.ts +95 -0
- package/dist/cockpit/terminal-session.js +123 -0
- package/dist/cockpit/view.d.ts +79 -0
- package/dist/cockpit/view.js +58 -0
- package/dist/demand/c8-rest.d.ts +77 -0
- package/dist/demand/c8-rest.js +123 -0
- package/dist/demand/index.d.ts +24 -0
- package/dist/demand/index.js +24 -0
- package/dist/demand/model.d.ts +68 -0
- package/dist/demand/model.js +118 -0
- package/dist/demand/taskdef.d.ts +40 -0
- package/dist/demand/taskdef.js +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/presence/family.d.ts +40 -0
- package/dist/presence/family.js +166 -0
- package/dist/presence/index.d.ts +19 -0
- package/dist/presence/index.js +17 -0
- package/dist/presence/schema.d.ts +20 -0
- package/dist/presence/schema.js +32 -0
- package/dist/presence/store.d.ts +130 -0
- package/dist/presence/store.js +191 -0
- package/dist/presence/test-db.d.ts +5 -0
- package/dist/presence/test-db.js +42 -0
- package/dist/protocol/conformance/frames.d.ts +24 -0
- package/dist/protocol/conformance/frames.js +116 -0
- package/dist/protocol/conformance/index.d.ts +13 -0
- package/dist/protocol/conformance/index.js +13 -0
- package/dist/protocol/conformance/malformed.d.ts +14 -0
- package/dist/protocol/conformance/malformed.js +44 -0
- package/dist/protocol/conformance/tokens.d.ts +19 -0
- package/dist/protocol/conformance/tokens.js +49 -0
- package/dist/protocol/conformance/vocab.d.ts +23 -0
- package/dist/protocol/conformance/vocab.js +97 -0
- package/dist/protocol/families.d.ts +32 -0
- package/dist/protocol/families.js +45 -0
- package/dist/protocol/frame.d.ts +45 -0
- package/dist/protocol/frame.js +114 -0
- package/dist/protocol/hex.d.ts +7 -0
- package/dist/protocol/hex.js +26 -0
- package/dist/protocol/index.d.ts +23 -0
- package/dist/protocol/index.js +23 -0
- package/dist/protocol/lanes.d.ts +36 -0
- package/dist/protocol/lanes.js +40 -0
- package/dist/protocol/payloads.d.ts +64 -0
- package/dist/protocol/payloads.js +122 -0
- package/dist/protocol/token.d.ts +34 -0
- package/dist/protocol/token.js +81 -0
- package/dist/protocol/vocab/schema.d.ts +51 -0
- package/dist/protocol/vocab/schema.js +218 -0
- package/dist/relay/incarnation.d.ts +17 -0
- package/dist/relay/incarnation.js +50 -0
- package/dist/relay/index.d.ts +25 -0
- package/dist/relay/index.js +22 -0
- package/dist/relay/relay-family.d.ts +68 -0
- package/dist/relay/relay-family.js +272 -0
- package/dist/relay/ring.d.ts +49 -0
- package/dist/relay/ring.js +105 -0
- package/dist/relay/scheduler.d.ts +72 -0
- package/dist/relay/scheduler.js +180 -0
- package/dist/relay/validate.d.ts +29 -0
- package/dist/relay/validate.js +39 -0
- package/dist/transcript/index.d.ts +18 -0
- package/dist/transcript/index.js +17 -0
- package/dist/transcript/schema.d.ts +32 -0
- package/dist/transcript/schema.js +48 -0
- package/dist/transcript/store.d.ts +192 -0
- package/dist/transcript/store.js +347 -0
- package/dist/transcript/test-db.d.ts +5 -0
- package/dist/transcript/test-db.js +41 -0
- package/dist/vocab/core-vocab.d.ts +26 -0
- package/dist/vocab/core-vocab.js +67 -0
- package/dist/vocab/diversity.d.ts +78 -0
- package/dist/vocab/diversity.js +89 -0
- package/dist/vocab/index.d.ts +22 -0
- package/dist/vocab/index.js +22 -0
- package/dist/vocab/merge.d.ts +9 -0
- package/dist/vocab/merge.js +104 -0
- package/dist/vocab/requires.d.ts +49 -0
- package/dist/vocab/requires.js +107 -0
- package/dist/vocab/resolver.d.ts +62 -0
- package/dist/vocab/resolver.js +149 -0
- package/dist/vocab/serve.d.ts +39 -0
- package/dist/vocab/serve.js +36 -0
- package/package.json +108 -0
- package/page/cockpit.css +114 -0
- package/page/cockpit.page.json +33 -0
- package/page/embed.html +40 -0
- package/page/mount.js +78 -0
- package/page/standalone.html +43 -0
- package/src/blackboard/family.test.ts +280 -0
- package/src/blackboard/family.ts +208 -0
- package/src/blackboard/index.ts +42 -0
- package/src/blackboard/schema.test.ts +60 -0
- package/src/blackboard/schema.ts +44 -0
- package/src/blackboard/store.test.ts +189 -0
- package/src/blackboard/store.ts +331 -0
- package/src/blackboard/test-db.ts +47 -0
- package/src/channel/auth.test.ts +64 -0
- package/src/channel/auth.ts +101 -0
- package/src/channel/clock.ts +14 -0
- package/src/channel/connection.ts +77 -0
- package/src/channel/dispatch.test.ts +83 -0
- package/src/channel/dispatch.ts +102 -0
- package/src/channel/hub.test.ts +335 -0
- package/src/channel/hub.ts +222 -0
- package/src/channel/index.ts +55 -0
- package/src/channel/registry.test.ts +73 -0
- package/src/channel/registry.ts +137 -0
- package/src/channel/ws-transport.test.ts +234 -0
- package/src/channel/ws-transport.ts +212 -0
- package/src/cockpit/boot.test.ts +374 -0
- package/src/cockpit/boot.ts +280 -0
- package/src/cockpit/fake-dom.ts +90 -0
- package/src/cockpit/index.ts +63 -0
- package/src/cockpit/relay-client.test.ts +359 -0
- package/src/cockpit/relay-client.ts +234 -0
- package/src/cockpit/render.test.ts +149 -0
- package/src/cockpit/render.ts +194 -0
- package/src/cockpit/terminal-session.test.ts +252 -0
- package/src/cockpit/terminal-session.ts +194 -0
- package/src/cockpit/view.test.ts +117 -0
- package/src/cockpit/view.ts +140 -0
- package/src/demand/c8-rest.test.ts +140 -0
- package/src/demand/c8-rest.ts +167 -0
- package/src/demand/index.ts +42 -0
- package/src/demand/model.test.ts +197 -0
- package/src/demand/model.ts +183 -0
- package/src/demand/taskdef.test.ts +85 -0
- package/src/demand/taskdef.ts +78 -0
- package/src/index.ts +17 -0
- package/src/presence/family.test.ts +252 -0
- package/src/presence/family.ts +205 -0
- package/src/presence/index.ts +26 -0
- package/src/presence/schema.test.ts +53 -0
- package/src/presence/schema.ts +34 -0
- package/src/presence/store.test.ts +190 -0
- package/src/presence/store.ts +287 -0
- package/src/presence/test-db.test.ts +57 -0
- package/src/presence/test-db.ts +47 -0
- package/src/protocol/conformance/corpus.test.ts +66 -0
- package/src/protocol/conformance/frames.ts +142 -0
- package/src/protocol/conformance/index.ts +29 -0
- package/src/protocol/conformance/malformed.ts +59 -0
- package/src/protocol/conformance/tokens.ts +70 -0
- package/src/protocol/conformance/vocab.ts +122 -0
- package/src/protocol/families.ts +54 -0
- package/src/protocol/frame.test.ts +116 -0
- package/src/protocol/frame.ts +171 -0
- package/src/protocol/hex.ts +28 -0
- package/src/protocol/index.ts +84 -0
- package/src/protocol/lanes.test.ts +82 -0
- package/src/protocol/lanes.ts +54 -0
- package/src/protocol/payloads.test.ts +91 -0
- package/src/protocol/payloads.ts +201 -0
- package/src/protocol/token.test.ts +57 -0
- package/src/protocol/token.ts +123 -0
- package/src/protocol/vocab/schema.test.ts +67 -0
- package/src/protocol/vocab/schema.ts +281 -0
- package/src/relay/incarnation.test.ts +53 -0
- package/src/relay/incarnation.ts +54 -0
- package/src/relay/index.ts +34 -0
- package/src/relay/integration.test.ts +135 -0
- package/src/relay/relay-family.test.ts +236 -0
- package/src/relay/relay-family.ts +336 -0
- package/src/relay/ring.test.ts +138 -0
- package/src/relay/ring.ts +136 -0
- package/src/relay/scheduler.test.ts +233 -0
- package/src/relay/scheduler.ts +208 -0
- package/src/relay/validate.test.ts +43 -0
- package/src/relay/validate.ts +44 -0
- package/src/transcript/index.ts +33 -0
- package/src/transcript/integration.test.ts +108 -0
- package/src/transcript/schema.test.ts +69 -0
- package/src/transcript/schema.ts +51 -0
- package/src/transcript/store.test.ts +285 -0
- package/src/transcript/store.ts +530 -0
- package/src/transcript/test-db.ts +46 -0
- package/src/vocab/core-vocab.test.ts +34 -0
- package/src/vocab/core-vocab.ts +88 -0
- package/src/vocab/diversity.test.ts +153 -0
- package/src/vocab/diversity.ts +169 -0
- package/src/vocab/index.ts +55 -0
- package/src/vocab/merge.test.ts +73 -0
- package/src/vocab/merge.ts +117 -0
- package/src/vocab/requires.test.ts +69 -0
- package/src/vocab/requires.ts +155 -0
- package/src/vocab/resolver.test.ts +118 -0
- package/src/vocab/resolver.ts +187 -0
- package/src/vocab/serve.test.ts +64 -0
- package/src/vocab/serve.ts +66 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal synchronous SQLite handle the store needs — structurally the same
|
|
3
|
+
* surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local
|
|
4
|
+
* so the store depends on a shape, not on the runtime package. (Identical to the
|
|
5
|
+
* S2 presence store's `SqliteDb`.)
|
|
6
|
+
*/
|
|
7
|
+
export interface SqliteDb {
|
|
8
|
+
/** Execute one or more statements with no result (DDL, migrations). */
|
|
9
|
+
exec(sql: string): void;
|
|
10
|
+
/** Run a parameterised statement, returning the changed-row count. */
|
|
11
|
+
run(sql: string, params?: unknown[]): {
|
|
12
|
+
changes: number;
|
|
13
|
+
lastInsertRowid: number | bigint;
|
|
14
|
+
};
|
|
15
|
+
/** Run a parameterised query, returning all rows as plain objects. */
|
|
16
|
+
all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
|
|
17
|
+
}
|
|
18
|
+
/** A monotonic wall clock, injectable for deterministic tests. */
|
|
19
|
+
export interface Clock {
|
|
20
|
+
now(): number;
|
|
21
|
+
}
|
|
22
|
+
/** The default clock: `Date.now()`. */
|
|
23
|
+
export declare const systemClock: Clock;
|
|
24
|
+
/**
|
|
25
|
+
* A stream's retention lifecycle. `ephemeral` transcripts are flushed once on job
|
|
26
|
+
* completion and retained until a retention sweep; `long-lived` transcripts grow
|
|
27
|
+
* incrementally and are bounded by a rolling offset window.
|
|
28
|
+
*/
|
|
29
|
+
export type TranscriptLifecycle = "ephemeral" | "long-lived";
|
|
30
|
+
/** A stream's transcript status. */
|
|
31
|
+
export type TranscriptStatus = "open" | "completed";
|
|
32
|
+
/** A single durable transcript chunk and the offset it was assigned. */
|
|
33
|
+
export interface TranscriptChunk {
|
|
34
|
+
readonly offset: number;
|
|
35
|
+
readonly chunk: string;
|
|
36
|
+
}
|
|
37
|
+
/** Per-stream transcript metadata. */
|
|
38
|
+
export interface TranscriptStream {
|
|
39
|
+
readonly stream: string;
|
|
40
|
+
readonly lifecycle: TranscriptLifecycle;
|
|
41
|
+
readonly status: TranscriptStatus;
|
|
42
|
+
/** When the stream was first opened, ISO-8601. */
|
|
43
|
+
readonly createdAt: string;
|
|
44
|
+
/** When an ephemeral run was flushed & completed, ISO-8601 (undefined while open). */
|
|
45
|
+
readonly completedAt?: string;
|
|
46
|
+
/** The oldest retained offset, or undefined when the transcript is empty. */
|
|
47
|
+
readonly firstOffset?: number;
|
|
48
|
+
/** One past the highest offset ever recorded (the resume high-water mark). */
|
|
49
|
+
readonly nextOffset: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The result of a {@link TranscriptStore.since} reattach query — the same shape
|
|
53
|
+
* the S5 {@link ReplayRing.since} returns, now served from durable storage.
|
|
54
|
+
*/
|
|
55
|
+
export interface TranscriptSlice {
|
|
56
|
+
/** The retained chunks with `offset >= from`, in offset order. */
|
|
57
|
+
readonly entries: readonly TranscriptChunk[];
|
|
58
|
+
/**
|
|
59
|
+
* `true` when `from` predates the oldest retained offset: chunks the consumer
|
|
60
|
+
* asked for were already dropped by retention (rolling window / expiry), so the
|
|
61
|
+
* replay is a best-effort resume, not gap-free from `from`.
|
|
62
|
+
*/
|
|
63
|
+
readonly gap: boolean;
|
|
64
|
+
/** One past the highest recorded offset (where the live stream continues). */
|
|
65
|
+
readonly nextOffset: number;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The minimal resume-from-offset source a {@link TranscriptStore.flush} reads.
|
|
69
|
+
* The S5 {@link ReplayRing} satisfies this structurally (`since(0).entries` is the
|
|
70
|
+
* whole retained window; `nextOffset` is its high-water mark), so the store can
|
|
71
|
+
* flush a real relay ring without a compile dependency on the relay package.
|
|
72
|
+
*/
|
|
73
|
+
export interface TranscriptRing {
|
|
74
|
+
since(from: number): {
|
|
75
|
+
readonly entries: readonly TranscriptChunk[];
|
|
76
|
+
};
|
|
77
|
+
readonly nextOffset: number;
|
|
78
|
+
}
|
|
79
|
+
export interface TranscriptStoreOptions {
|
|
80
|
+
/**
|
|
81
|
+
* How long a *completed ephemeral* transcript is retained after its
|
|
82
|
+
* `completed_at` before {@link TranscriptStore.sweep} may drop it, in ms.
|
|
83
|
+
* Default 86_400_000 (24h). Long-lived streams are never time-swept.
|
|
84
|
+
*/
|
|
85
|
+
ephemeralRetentionMs?: number;
|
|
86
|
+
/** Injectable clock for deterministic tests. Default {@link systemClock}. */
|
|
87
|
+
clock?: Clock;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Raised when a transcript row read back from storage holds a value outside its
|
|
91
|
+
* domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
|
|
92
|
+
* bad manual write. Fail fast rather than silently coercing to a default, which
|
|
93
|
+
* would mask the corruption and skew retention decisions.
|
|
94
|
+
*/
|
|
95
|
+
export declare class TranscriptCorruptionError extends Error {
|
|
96
|
+
constructor(message: string);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Raised when an operation targets a lifecycle it does not apply to (e.g.
|
|
100
|
+
* completing a `long-lived` stream, which by definition never completes).
|
|
101
|
+
*/
|
|
102
|
+
export declare class TranscriptLifecycleError extends Error {
|
|
103
|
+
readonly stream: string;
|
|
104
|
+
constructor(stream: string, message: string);
|
|
105
|
+
}
|
|
106
|
+
export declare class TranscriptStore {
|
|
107
|
+
#private;
|
|
108
|
+
constructor(db: SqliteDb, options?: TranscriptStoreOptions);
|
|
109
|
+
/** The completed-ephemeral retention window in ms. */
|
|
110
|
+
get ephemeralRetentionMs(): number;
|
|
111
|
+
/**
|
|
112
|
+
* Apply the canonical transcript DDL (idempotent). Callers that let the app
|
|
113
|
+
* DataLayer migration runner apply `db/migrations/002_agentic_transcript.sql`
|
|
114
|
+
* do not need this — but it is provided so the store is usable against a bare
|
|
115
|
+
* source too. The DDL is identical to the migration (drift-guarded).
|
|
116
|
+
*/
|
|
117
|
+
ensureSchema(): void;
|
|
118
|
+
/**
|
|
119
|
+
* Open (or fetch) a stream's transcript with the given lifecycle. Idempotent:
|
|
120
|
+
* a first call stamps `created_at` and the lifecycle; later calls return the
|
|
121
|
+
* existing row unchanged (lifecycle is first-wins and never mutates). Returns
|
|
122
|
+
* the stored metadata row.
|
|
123
|
+
*/
|
|
124
|
+
open(stream: string, lifecycle: TranscriptLifecycle): TranscriptStream;
|
|
125
|
+
/**
|
|
126
|
+
* Record chunks into a stream's durable transcript, idempotently. Each chunk is
|
|
127
|
+
* keyed `(stream, offset)`, so re-recording an already-stored offset (a retry, a
|
|
128
|
+
* re-flush, an overlapping reattach) is a no-op — never a duplicate. Auto-opens
|
|
129
|
+
* the stream with `lifecycle` (default `long-lived`) if it is not open yet; if the
|
|
130
|
+
* stream already exists under a different lifecycle this throws a
|
|
131
|
+
* {@link TranscriptLifecycleError} before writing anything (lifecycle is
|
|
132
|
+
* first-wins), so a mismatched flush cannot leave a partial write.
|
|
133
|
+
* The batch is atomic: if any entry has an invalid offset (or a write fails)
|
|
134
|
+
* partway through, the whole call rolls back — it records every chunk or none.
|
|
135
|
+
* Returns the number of newly-persisted chunks.
|
|
136
|
+
*
|
|
137
|
+
* This is the incremental path a long-lived stream uses; {@link flush} builds on
|
|
138
|
+
* it for the ephemeral completion path.
|
|
139
|
+
*/
|
|
140
|
+
record(stream: string, entries: Iterable<TranscriptChunk>, lifecycle?: TranscriptLifecycle): number;
|
|
141
|
+
/**
|
|
142
|
+
* Flush a resume-from-offset source (an S5 {@link ReplayRing}) into a stream's
|
|
143
|
+
* durable transcript. Persists the source's entire retained window
|
|
144
|
+
* (`source.since(0)`) idempotently and advances the stream's high-water mark to
|
|
145
|
+
* `source.nextOffset` (so the recorded `nextOffset` reflects everything ever
|
|
146
|
+
* produced, even chunks the ring already evicted). Returns the number of
|
|
147
|
+
* newly-persisted chunks.
|
|
148
|
+
*
|
|
149
|
+
* For an `ephemeral` stream this is the job-completion flush: it also marks the
|
|
150
|
+
* transcript `completed` (stamping `completed_at`), after which {@link read}
|
|
151
|
+
* yields the durable transcript and {@link sweep} may later retire it. For a
|
|
152
|
+
* `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`.
|
|
153
|
+
*/
|
|
154
|
+
flush(stream: string, source: TranscriptRing, lifecycle: TranscriptLifecycle): number;
|
|
155
|
+
/**
|
|
156
|
+
* Reattach a consumer from offset `from` (inclusive). Returns the retained
|
|
157
|
+
* chunks with `offset >= from`, the live `nextOffset`, and a `gap` flag when
|
|
158
|
+
* `from` predates the oldest retained offset (retention dropped chunks the
|
|
159
|
+
* consumer wanted). Mirrors the S5 {@link ReplayRing.since} contract exactly,
|
|
160
|
+
* so a reattach behaves identically whether it resumes from the live ring or
|
|
161
|
+
* the durable transcript.
|
|
162
|
+
*/
|
|
163
|
+
since(stream: string, from: number): TranscriptSlice;
|
|
164
|
+
/** Read a stream's whole durable transcript in offset order. */
|
|
165
|
+
read(stream: string): TranscriptChunk[];
|
|
166
|
+
/**
|
|
167
|
+
* Apply a rolling retention window to a long-lived stream: drop every chunk with
|
|
168
|
+
* `offset < before`. A subsequent {@link since} from an offset older than
|
|
169
|
+
* `before` reports a `gap`. Returns the number of chunks dropped. Refuses to
|
|
170
|
+
* truncate an `ephemeral` transcript (those are retained whole until swept) with
|
|
171
|
+
* a {@link TranscriptLifecycleError}.
|
|
172
|
+
*/
|
|
173
|
+
truncateBefore(stream: string, before: number): number;
|
|
174
|
+
/**
|
|
175
|
+
* Retention sweep for completed ephemeral transcripts: drop every stream whose
|
|
176
|
+
* `status = 'completed'` and whose `completed_at` is older than the retention
|
|
177
|
+
* window, along with its chunks. Long-lived streams are never time-swept (they
|
|
178
|
+
* are bounded by {@link truncateBefore} instead). Returns the removed stream ids.
|
|
179
|
+
*
|
|
180
|
+
* The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
|
|
181
|
+
* sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
|
|
182
|
+
* back, so the DB is never left partially swept and the returned list always
|
|
183
|
+
* matches what was actually deleted.
|
|
184
|
+
*/
|
|
185
|
+
sweep(now?: number): string[];
|
|
186
|
+
/** Look up a single stream's transcript metadata. */
|
|
187
|
+
get(stream: string): TranscriptStream | undefined;
|
|
188
|
+
/** Every stream's metadata, ordered by first open then stream id. */
|
|
189
|
+
list(): TranscriptStream[];
|
|
190
|
+
/** Number of tracked streams. */
|
|
191
|
+
count(): number;
|
|
192
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
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.js";
|
|
26
|
+
/** The default clock: `Date.now()`. */
|
|
27
|
+
export const systemClock = { now: () => Date.now() };
|
|
28
|
+
const DEFAULT_EPHEMERAL_RETENTION_MS = 86_400_000;
|
|
29
|
+
function isNonNegInt(value) {
|
|
30
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A recordable offset must be a non-negative safe integer that still leaves room
|
|
34
|
+
* for its successor: `#refreshWindow` derives `next_offset = maxOffset + 1`, so an
|
|
35
|
+
* offset of `Number.MAX_SAFE_INTEGER` would make `next_offset` a non-safe integer
|
|
36
|
+
* and break the resume contract (`since()` rejects non-safe integers). Cap one
|
|
37
|
+
* below the safe-integer ceiling.
|
|
38
|
+
*/
|
|
39
|
+
function isRecordableOffset(value) {
|
|
40
|
+
return isNonNegInt(value) && value < Number.MAX_SAFE_INTEGER;
|
|
41
|
+
}
|
|
42
|
+
function toLifecycle(value) {
|
|
43
|
+
if (value === "ephemeral" || value === "long-lived")
|
|
44
|
+
return value;
|
|
45
|
+
throw new TranscriptCorruptionError(`invalid transcript lifecycle in DB: ${JSON.stringify(value)}`);
|
|
46
|
+
}
|
|
47
|
+
function toStatus(value) {
|
|
48
|
+
if (value === "open" || value === "completed")
|
|
49
|
+
return value;
|
|
50
|
+
throw new TranscriptCorruptionError(`invalid transcript status in DB: ${JSON.stringify(value)}`);
|
|
51
|
+
}
|
|
52
|
+
function toStream(row) {
|
|
53
|
+
const out = {
|
|
54
|
+
stream: row.stream,
|
|
55
|
+
lifecycle: toLifecycle(row.lifecycle),
|
|
56
|
+
status: toStatus(row.status),
|
|
57
|
+
createdAt: row.created_at,
|
|
58
|
+
nextOffset: row.next_offset,
|
|
59
|
+
};
|
|
60
|
+
if (row.completed_at !== null)
|
|
61
|
+
out.completedAt = row.completed_at;
|
|
62
|
+
if (row.first_offset !== null)
|
|
63
|
+
out.firstOffset = row.first_offset;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Raised when a transcript row read back from storage holds a value outside its
|
|
68
|
+
* domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
|
|
69
|
+
* bad manual write. Fail fast rather than silently coercing to a default, which
|
|
70
|
+
* would mask the corruption and skew retention decisions.
|
|
71
|
+
*/
|
|
72
|
+
export class TranscriptCorruptionError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "TranscriptCorruptionError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Raised when an operation targets a lifecycle it does not apply to (e.g.
|
|
80
|
+
* completing a `long-lived` stream, which by definition never completes).
|
|
81
|
+
*/
|
|
82
|
+
export class TranscriptLifecycleError extends Error {
|
|
83
|
+
stream;
|
|
84
|
+
constructor(stream, message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "TranscriptLifecycleError";
|
|
87
|
+
this.stream = stream;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class TranscriptStore {
|
|
91
|
+
#db;
|
|
92
|
+
#ephemeralRetentionMs;
|
|
93
|
+
#clock;
|
|
94
|
+
constructor(db, options = {}) {
|
|
95
|
+
const retentionMs = options.ephemeralRetentionMs ?? DEFAULT_EPHEMERAL_RETENTION_MS;
|
|
96
|
+
if (!(typeof retentionMs === "number" && Number.isFinite(retentionMs) && retentionMs >= 0)) {
|
|
97
|
+
throw new RangeError(`ephemeralRetentionMs must be a finite non-negative number, got ${retentionMs}`);
|
|
98
|
+
}
|
|
99
|
+
this.#db = db;
|
|
100
|
+
this.#ephemeralRetentionMs = retentionMs;
|
|
101
|
+
this.#clock = options.clock ?? systemClock;
|
|
102
|
+
}
|
|
103
|
+
/** The completed-ephemeral retention window in ms. */
|
|
104
|
+
get ephemeralRetentionMs() {
|
|
105
|
+
return this.#ephemeralRetentionMs;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply the canonical transcript DDL (idempotent). Callers that let the app
|
|
109
|
+
* DataLayer migration runner apply `db/migrations/002_agentic_transcript.sql`
|
|
110
|
+
* do not need this — but it is provided so the store is usable against a bare
|
|
111
|
+
* source too. The DDL is identical to the migration (drift-guarded).
|
|
112
|
+
*/
|
|
113
|
+
ensureSchema() {
|
|
114
|
+
this.#db.exec(TRANSCRIPT_SCHEMA_SQL);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Open (or fetch) a stream's transcript with the given lifecycle. Idempotent:
|
|
118
|
+
* a first call stamps `created_at` and the lifecycle; later calls return the
|
|
119
|
+
* existing row unchanged (lifecycle is first-wins and never mutates). Returns
|
|
120
|
+
* the stored metadata row.
|
|
121
|
+
*/
|
|
122
|
+
open(stream, lifecycle) {
|
|
123
|
+
this.#db.run(`INSERT INTO ${TRANSCRIPT_STREAM_TABLE} (stream, lifecycle, status, created_at, next_offset)
|
|
124
|
+
VALUES (?, ?, 'open', ?, 0)
|
|
125
|
+
ON CONFLICT(stream) DO NOTHING`, [stream, lifecycle, new Date(this.#clock.now()).toISOString()]);
|
|
126
|
+
const row = this.get(stream);
|
|
127
|
+
if (row === undefined) {
|
|
128
|
+
throw new Error(`transcript stream row vanished immediately after open: ${stream}`);
|
|
129
|
+
}
|
|
130
|
+
return row;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Record chunks into a stream's durable transcript, idempotently. Each chunk is
|
|
134
|
+
* keyed `(stream, offset)`, so re-recording an already-stored offset (a retry, a
|
|
135
|
+
* re-flush, an overlapping reattach) is a no-op — never a duplicate. Auto-opens
|
|
136
|
+
* the stream with `lifecycle` (default `long-lived`) if it is not open yet; if the
|
|
137
|
+
* stream already exists under a different lifecycle this throws a
|
|
138
|
+
* {@link TranscriptLifecycleError} before writing anything (lifecycle is
|
|
139
|
+
* first-wins), so a mismatched flush cannot leave a partial write.
|
|
140
|
+
* The batch is atomic: if any entry has an invalid offset (or a write fails)
|
|
141
|
+
* partway through, the whole call rolls back — it records every chunk or none.
|
|
142
|
+
* Returns the number of newly-persisted chunks.
|
|
143
|
+
*
|
|
144
|
+
* This is the incremental path a long-lived stream uses; {@link flush} builds on
|
|
145
|
+
* it for the ephemeral completion path.
|
|
146
|
+
*/
|
|
147
|
+
record(stream, entries, lifecycle = "long-lived") {
|
|
148
|
+
const meta = this.open(stream, lifecycle);
|
|
149
|
+
if (meta.lifecycle !== lifecycle) {
|
|
150
|
+
throw new TranscriptLifecycleError(stream, `refusing to record into "${stream}" with lifecycle=${lifecycle}; the stream is ${meta.lifecycle} (lifecycle is first-wins)`);
|
|
151
|
+
}
|
|
152
|
+
const at = new Date(this.#clock.now()).toISOString();
|
|
153
|
+
// Make the batch write + window refresh all-or-nothing. If a later entry is
|
|
154
|
+
// invalid (or any write throws) partway through, the SAVEPOINT rolls back so
|
|
155
|
+
// record() never leaves partially-persisted chunks or stale first/next-offset
|
|
156
|
+
// metadata — it either records the whole batch or nothing.
|
|
157
|
+
return this.#atomic(() => {
|
|
158
|
+
let written = 0;
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (!isRecordableOffset(entry.offset)) {
|
|
161
|
+
throw new RangeError(`transcript offset must be a non-negative safe integer below Number.MAX_SAFE_INTEGER, got ${entry.offset}`);
|
|
162
|
+
}
|
|
163
|
+
const { changes } = this.#db.run(`INSERT INTO ${TRANSCRIPT_CHUNK_TABLE} (stream, chunk_offset, chunk, appended_at)
|
|
164
|
+
VALUES (?, ?, ?, ?)
|
|
165
|
+
ON CONFLICT(stream, chunk_offset) DO NOTHING`, [stream, entry.offset, entry.chunk, at]);
|
|
166
|
+
written += changes;
|
|
167
|
+
}
|
|
168
|
+
this.#refreshWindow(stream);
|
|
169
|
+
return written;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Flush a resume-from-offset source (an S5 {@link ReplayRing}) into a stream's
|
|
174
|
+
* durable transcript. Persists the source's entire retained window
|
|
175
|
+
* (`source.since(0)`) idempotently and advances the stream's high-water mark to
|
|
176
|
+
* `source.nextOffset` (so the recorded `nextOffset` reflects everything ever
|
|
177
|
+
* produced, even chunks the ring already evicted). Returns the number of
|
|
178
|
+
* newly-persisted chunks.
|
|
179
|
+
*
|
|
180
|
+
* For an `ephemeral` stream this is the job-completion flush: it also marks the
|
|
181
|
+
* transcript `completed` (stamping `completed_at`), after which {@link read}
|
|
182
|
+
* yields the durable transcript and {@link sweep} may later retire it. For a
|
|
183
|
+
* `long-lived` stream it is a snapshot checkpoint that leaves the stream `open`.
|
|
184
|
+
*/
|
|
185
|
+
flush(stream, source, lifecycle) {
|
|
186
|
+
const written = this.record(stream, source.since(0).entries, lifecycle);
|
|
187
|
+
// Advance the high-water mark to the source's nextOffset. The ring may have
|
|
188
|
+
// evicted early chunks, so its nextOffset can exceed maxStoredOffset+1; the
|
|
189
|
+
// recorded next_offset must reflect the true stream length for a later
|
|
190
|
+
// reattach's gap accounting to be correct.
|
|
191
|
+
if (isNonNegInt(source.nextOffset)) {
|
|
192
|
+
this.#raiseNextOffset(stream, source.nextOffset);
|
|
193
|
+
}
|
|
194
|
+
if (lifecycle === "ephemeral") {
|
|
195
|
+
this.#complete(stream);
|
|
196
|
+
}
|
|
197
|
+
return written;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Reattach a consumer from offset `from` (inclusive). Returns the retained
|
|
201
|
+
* chunks with `offset >= from`, the live `nextOffset`, and a `gap` flag when
|
|
202
|
+
* `from` predates the oldest retained offset (retention dropped chunks the
|
|
203
|
+
* consumer wanted). Mirrors the S5 {@link ReplayRing.since} contract exactly,
|
|
204
|
+
* so a reattach behaves identically whether it resumes from the live ring or
|
|
205
|
+
* the durable transcript.
|
|
206
|
+
*/
|
|
207
|
+
since(stream, from) {
|
|
208
|
+
if (!isNonNegInt(from)) {
|
|
209
|
+
throw new RangeError(`since(from) requires a non-negative safe integer, got ${from}`);
|
|
210
|
+
}
|
|
211
|
+
const meta = this.get(stream);
|
|
212
|
+
if (meta === undefined) {
|
|
213
|
+
return { entries: [], gap: false, nextOffset: 0 };
|
|
214
|
+
}
|
|
215
|
+
const entries = this.#db
|
|
216
|
+
.all(`SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE}
|
|
217
|
+
WHERE stream = ? AND chunk_offset >= ? ORDER BY chunk_offset`, [stream, from])
|
|
218
|
+
.map((r) => ({ offset: r.chunk_offset, chunk: r.chunk }));
|
|
219
|
+
// A gap means the consumer asked for an offset older than anything retained.
|
|
220
|
+
// Only meaningful while chunks remain: with an empty (fully-swept or freshly
|
|
221
|
+
// opened) transcript there is nothing to have "lost", so gap is false.
|
|
222
|
+
const gap = meta.firstOffset !== undefined && from < meta.firstOffset;
|
|
223
|
+
return { entries, gap, nextOffset: meta.nextOffset };
|
|
224
|
+
}
|
|
225
|
+
/** Read a stream's whole durable transcript in offset order. */
|
|
226
|
+
read(stream) {
|
|
227
|
+
return this.#db
|
|
228
|
+
.all(`SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? ORDER BY chunk_offset`, [stream])
|
|
229
|
+
.map((r) => ({ offset: r.chunk_offset, chunk: r.chunk }));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Apply a rolling retention window to a long-lived stream: drop every chunk with
|
|
233
|
+
* `offset < before`. A subsequent {@link since} from an offset older than
|
|
234
|
+
* `before` reports a `gap`. Returns the number of chunks dropped. Refuses to
|
|
235
|
+
* truncate an `ephemeral` transcript (those are retained whole until swept) with
|
|
236
|
+
* a {@link TranscriptLifecycleError}.
|
|
237
|
+
*/
|
|
238
|
+
truncateBefore(stream, before) {
|
|
239
|
+
if (!isNonNegInt(before)) {
|
|
240
|
+
throw new RangeError(`truncateBefore(before) requires a non-negative safe integer, got ${before}`);
|
|
241
|
+
}
|
|
242
|
+
const meta = this.get(stream);
|
|
243
|
+
if (meta === undefined)
|
|
244
|
+
return 0;
|
|
245
|
+
if (meta.lifecycle === "ephemeral") {
|
|
246
|
+
throw new TranscriptLifecycleError(stream, `refusing to truncate ephemeral transcript "${stream}"; ephemeral runs are retained whole until sweep()`);
|
|
247
|
+
}
|
|
248
|
+
const { changes } = this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? AND chunk_offset < ?`, [stream, before]);
|
|
249
|
+
if (changes > 0)
|
|
250
|
+
this.#refreshWindow(stream);
|
|
251
|
+
return changes;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Retention sweep for completed ephemeral transcripts: drop every stream whose
|
|
255
|
+
* `status = 'completed'` and whose `completed_at` is older than the retention
|
|
256
|
+
* window, along with its chunks. Long-lived streams are never time-swept (they
|
|
257
|
+
* are bounded by {@link truncateBefore} instead). Returns the removed stream ids.
|
|
258
|
+
*
|
|
259
|
+
* The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
|
|
260
|
+
* sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
|
|
261
|
+
* back, so the DB is never left partially swept and the returned list always
|
|
262
|
+
* matches what was actually deleted.
|
|
263
|
+
*/
|
|
264
|
+
sweep(now = this.#clock.now()) {
|
|
265
|
+
const cutoffIso = new Date(now - this.#ephemeralRetentionMs).toISOString();
|
|
266
|
+
return this.#atomic(() => {
|
|
267
|
+
const removed = this.#db
|
|
268
|
+
.all(`SELECT stream FROM ${TRANSCRIPT_STREAM_TABLE}
|
|
269
|
+
WHERE lifecycle = 'ephemeral' AND status = 'completed'
|
|
270
|
+
AND completed_at IS NOT NULL AND completed_at < ?`, [cutoffIso])
|
|
271
|
+
.map((r) => r.stream);
|
|
272
|
+
for (const stream of removed) {
|
|
273
|
+
this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream]);
|
|
274
|
+
this.#db.run(`DELETE FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
|
|
275
|
+
}
|
|
276
|
+
return removed;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/** Look up a single stream's transcript metadata. */
|
|
280
|
+
get(stream) {
|
|
281
|
+
const rows = this.#db.all(`SELECT * FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
|
|
282
|
+
const row = rows[0];
|
|
283
|
+
return row === undefined ? undefined : toStream(row);
|
|
284
|
+
}
|
|
285
|
+
/** Every stream's metadata, ordered by first open then stream id. */
|
|
286
|
+
list() {
|
|
287
|
+
return this.#db
|
|
288
|
+
.all(`SELECT * FROM ${TRANSCRIPT_STREAM_TABLE} ORDER BY created_at, stream`)
|
|
289
|
+
.map(toStream);
|
|
290
|
+
}
|
|
291
|
+
/** Number of tracked streams. */
|
|
292
|
+
count() {
|
|
293
|
+
const rows = this.#db.all(`SELECT COUNT(*) AS n FROM ${TRANSCRIPT_STREAM_TABLE}`);
|
|
294
|
+
return rows[0]?.n ?? 0;
|
|
295
|
+
}
|
|
296
|
+
/** Mark an ephemeral stream completed (idempotent), stamping `completed_at`. */
|
|
297
|
+
#complete(stream) {
|
|
298
|
+
const meta = this.get(stream);
|
|
299
|
+
if (meta === undefined)
|
|
300
|
+
return;
|
|
301
|
+
if (meta.lifecycle !== "ephemeral") {
|
|
302
|
+
throw new TranscriptLifecycleError(stream, `refusing to complete non-ephemeral transcript "${stream}" (lifecycle=${meta.lifecycle})`);
|
|
303
|
+
}
|
|
304
|
+
if (meta.status === "completed")
|
|
305
|
+
return;
|
|
306
|
+
this.#db.run(`UPDATE ${TRANSCRIPT_STREAM_TABLE} SET status = 'completed', completed_at = ? WHERE stream = ?`, [new Date(this.#clock.now()).toISOString(), stream]);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Recompute a stream's retained offset window (`first_offset`, and `next_offset`
|
|
310
|
+
* raised to `max(chunk_offset)+1`) from the chunks actually stored. `next_offset`
|
|
311
|
+
* is monotonic — it is never lowered below its recorded high-water mark, so a
|
|
312
|
+
* truncation that drops the tail (there is none — truncation drops the head) or
|
|
313
|
+
* a re-record cannot rewind the resume point.
|
|
314
|
+
*/
|
|
315
|
+
#refreshWindow(stream) {
|
|
316
|
+
const agg = this.#db.all(`SELECT MIN(chunk_offset) AS mn, MAX(chunk_offset) AS mx FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream])[0];
|
|
317
|
+
const minOffset = agg?.mn ?? null;
|
|
318
|
+
const maxOffset = agg?.mx ?? null;
|
|
319
|
+
const candidateNext = maxOffset === null ? 0 : maxOffset + 1;
|
|
320
|
+
this.#db.run(`UPDATE ${TRANSCRIPT_STREAM_TABLE}
|
|
321
|
+
SET first_offset = ?, next_offset = MAX(next_offset, ?)
|
|
322
|
+
WHERE stream = ?`, [minOffset, candidateNext, stream]);
|
|
323
|
+
}
|
|
324
|
+
/** Raise a stream's `next_offset` high-water mark (never lowers it). */
|
|
325
|
+
#raiseNextOffset(stream, nextOffset) {
|
|
326
|
+
this.#db.run(`UPDATE ${TRANSCRIPT_STREAM_TABLE} SET next_offset = MAX(next_offset, ?) WHERE stream = ?`, [nextOffset, stream]);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Run `body` inside a SQLite SAVEPOINT so its writes are atomic: on any throw
|
|
330
|
+
* the savepoint is rolled back (nothing it wrote persists) and the error is
|
|
331
|
+
* re-raised; on success it is released. SAVEPOINTs nest, so this is safe
|
|
332
|
+
* whether or not an outer transaction is already open.
|
|
333
|
+
*/
|
|
334
|
+
#atomic(body) {
|
|
335
|
+
this.#db.exec("SAVEPOINT nano_atomic");
|
|
336
|
+
try {
|
|
337
|
+
const result = body();
|
|
338
|
+
this.#db.exec("RELEASE SAVEPOINT nano_atomic");
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
this.#db.exec("ROLLBACK TO SAVEPOINT nano_atomic");
|
|
343
|
+
this.#db.exec("RELEASE SAVEPOINT nano_atomic");
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
function toParams(params) {
|
|
10
|
+
return params.map((p) => {
|
|
11
|
+
if (p === null)
|
|
12
|
+
return null;
|
|
13
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || p instanceof Uint8Array) {
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
if (typeof p === "boolean")
|
|
17
|
+
return p ? 1 : 0;
|
|
18
|
+
// Mirror wrapNodeSqlite's `sqliteParams`: unsupported types (including
|
|
19
|
+
// `undefined`) throw rather than coerce, so a test can never pass on a
|
|
20
|
+
// parameter production would reject.
|
|
21
|
+
throw new TypeError(`unsupported SQLite parameter type: ${typeof p}`);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function openTestDb() {
|
|
25
|
+
const db = new DatabaseSync(":memory:");
|
|
26
|
+
return {
|
|
27
|
+
exec: (sql) => db.exec(sql),
|
|
28
|
+
run: (sql, params = []) => {
|
|
29
|
+
const stmt = db.prepare(sql);
|
|
30
|
+
const r = stmt.run(...toParams(params));
|
|
31
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
32
|
+
},
|
|
33
|
+
all: (sql, params = []) => {
|
|
34
|
+
const stmt = db.prepare(sql);
|
|
35
|
+
// Mirror wrapNodeSqlite exactly: return the driver's row objects directly.
|
|
36
|
+
// biome-ignore lint/plugin: Node sqlite returns untyped row objects; SqliteDb.all<T> is the host adapter boundary.
|
|
37
|
+
return stmt.all(...toParams(params));
|
|
38
|
+
},
|
|
39
|
+
close: () => db.close(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opinionated core vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Ships working out of the box (S0 invariant 6): the standard agentic SDLC
|
|
5
|
+
* networks — `planning.*`, `qa.*`, `implementation.*`, `ci.*` — plus the bare
|
|
6
|
+
* `decide` role, each gated by an enrolment `requires` predicate and sized with
|
|
7
|
+
* seats. Authors EXTEND this in the SAME schema (see {@link mergeVocab}); there
|
|
8
|
+
* is no second schema for extensions.
|
|
9
|
+
*
|
|
10
|
+
* Seats & diversity: review roles carry two named seats `#red` / `#blue` with
|
|
11
|
+
* `seatsDistinctFamily: true` so the diversity SLO (S3) fails RED when both
|
|
12
|
+
* reviewers are the same family. Non-review roles leave `seatsDistinctFamily`
|
|
13
|
+
* off (warn-default): a same-family collision there is AMBER, not RED.
|
|
14
|
+
*
|
|
15
|
+
* Capability is NEVER in the token — `requires` is the registry gate, evaluated
|
|
16
|
+
* over the declared enrolment capability (cognition/weight/family/host).
|
|
17
|
+
*/
|
|
18
|
+
import type { VocabDocument } from "../protocol/index.ts";
|
|
19
|
+
/** The current core-vocabulary artifact version. */
|
|
20
|
+
export declare const CORE_VOCAB_VERSION = 1;
|
|
21
|
+
/**
|
|
22
|
+
* The frozen core vocabulary document. Deep-frozen so a consumer cannot mutate
|
|
23
|
+
* the shared artifact; author extensions go through {@link mergeVocab}, which
|
|
24
|
+
* returns a fresh document.
|
|
25
|
+
*/
|
|
26
|
+
export declare const CORE_VOCAB: VocabDocument;
|