@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,49 @@
|
|
|
1
|
+
/** A single retained chunk and the offset it was assigned. */
|
|
2
|
+
export interface ReplayEntry {
|
|
3
|
+
readonly offset: number;
|
|
4
|
+
readonly chunk: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ReplayRingOptions {
|
|
7
|
+
/** Maximum number of retained chunks. Must be a positive integer. */
|
|
8
|
+
readonly capacity: number;
|
|
9
|
+
}
|
|
10
|
+
/** The result of a {@link ReplayRing.since} query. */
|
|
11
|
+
export interface ReplaySlice {
|
|
12
|
+
/** The retained entries with `offset >= from`, in offset order. */
|
|
13
|
+
readonly entries: readonly ReplayEntry[];
|
|
14
|
+
/**
|
|
15
|
+
* `true` when `from` predates the oldest retained offset: some chunks the
|
|
16
|
+
* consumer asked for were already evicted, so the replay is not gap-free. The
|
|
17
|
+
* consumer should treat the returned tail as a best-effort resume, not a
|
|
18
|
+
* continuous stream from `from`.
|
|
19
|
+
*/
|
|
20
|
+
readonly gap: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare class ReplayRing {
|
|
23
|
+
#private;
|
|
24
|
+
readonly capacity: number;
|
|
25
|
+
constructor(options: ReplayRingOptions);
|
|
26
|
+
/** Number of chunks currently retained. */
|
|
27
|
+
get size(): number;
|
|
28
|
+
/** The offset the next {@link append} will assign (also the total ever appended). */
|
|
29
|
+
get nextOffset(): number;
|
|
30
|
+
/** The oldest retained offset, or `undefined` when nothing is retained. */
|
|
31
|
+
get firstOffset(): number | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Append a chunk, assigning it the next offset. When the ring is at capacity
|
|
34
|
+
* the oldest retained chunk is evicted first (the offset counter still
|
|
35
|
+
* advances, so offsets stay monotonic and gap-free across eviction).
|
|
36
|
+
*/
|
|
37
|
+
append(chunk: string): ReplayEntry;
|
|
38
|
+
/**
|
|
39
|
+
* Return the retained tail from offset `from` (inclusive), for resume. `from`
|
|
40
|
+
* is clamped to what is retained:
|
|
41
|
+
* - `from <= firstOffset` → the whole retained window; `gap` is `true` when
|
|
42
|
+
* `from` is strictly before the oldest retained offset (evicted chunks).
|
|
43
|
+
* - `firstOffset < from <= nextOffset` → the exact suffix from `from`; no gap.
|
|
44
|
+
* - `from > nextOffset` → empty (the consumer is ahead of the stream); no gap.
|
|
45
|
+
*/
|
|
46
|
+
since(from: number): ReplaySlice;
|
|
47
|
+
/** Drop every retained chunk. The offset counter is NOT reset (offsets stay monotonic). */
|
|
48
|
+
clear(): void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded replay ring — S5's resume-from-offset store for one relay stream.
|
|
3
|
+
*
|
|
4
|
+
* A live terminal stream is an append-only sequence of chunks. Each appended
|
|
5
|
+
* chunk is assigned a monotonic, gap-free `offset` (starting at 0). The ring
|
|
6
|
+
* retains only the most recent `capacity` chunks: older chunks are evicted so a
|
|
7
|
+
* long-running stream cannot grow unbounded.
|
|
8
|
+
*
|
|
9
|
+
* Resume-from-offset (the load-bearing property): when a consumer reconnects it
|
|
10
|
+
* asks for everything from the next offset it still needs. {@link ReplayRing.since}
|
|
11
|
+
* returns exactly the retained tail from that offset, and flags a `gap` when the
|
|
12
|
+
* requested offset predates what is still retained (i.e. the consumer was
|
|
13
|
+
* disconnected long enough that some chunks were evicted before it could resume).
|
|
14
|
+
* The stream itself survives the reconnect — the ring is the durable-enough
|
|
15
|
+
* window that makes resume possible.
|
|
16
|
+
*/
|
|
17
|
+
import { addSafeInt, isNonNegInt, isPosInt } from "./validate.js";
|
|
18
|
+
export class ReplayRing {
|
|
19
|
+
capacity;
|
|
20
|
+
// Fixed-size circular buffer: eviction overwrites the oldest slot and advances
|
|
21
|
+
// the head in O(1), instead of Array.shift() (O(n) on every append once the
|
|
22
|
+
// ring is at steady-state capacity — a hotspot for high-throughput streams).
|
|
23
|
+
#buffer = [];
|
|
24
|
+
#head = 0;
|
|
25
|
+
#count = 0;
|
|
26
|
+
#nextOffset = 0;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
if (!isPosInt(options.capacity)) {
|
|
29
|
+
throw new RangeError(`ReplayRing capacity must be a positive integer, got ${options.capacity}`);
|
|
30
|
+
}
|
|
31
|
+
this.capacity = options.capacity;
|
|
32
|
+
this.#buffer.length = options.capacity;
|
|
33
|
+
}
|
|
34
|
+
/** Number of chunks currently retained. */
|
|
35
|
+
get size() {
|
|
36
|
+
return this.#count;
|
|
37
|
+
}
|
|
38
|
+
/** The offset the next {@link append} will assign (also the total ever appended). */
|
|
39
|
+
get nextOffset() {
|
|
40
|
+
return this.#nextOffset;
|
|
41
|
+
}
|
|
42
|
+
/** The oldest retained offset, or `undefined` when nothing is retained. */
|
|
43
|
+
get firstOffset() {
|
|
44
|
+
return this.#count === 0 ? undefined : this.#buffer[this.#head]?.offset;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Append a chunk, assigning it the next offset. When the ring is at capacity
|
|
48
|
+
* the oldest retained chunk is evicted first (the offset counter still
|
|
49
|
+
* advances, so offsets stay monotonic and gap-free across eviction).
|
|
50
|
+
*/
|
|
51
|
+
append(chunk) {
|
|
52
|
+
// Fail fast before mutating any state: compute the next offset first so an
|
|
53
|
+
// overflow throws atomically (the ring is left untouched) rather than after
|
|
54
|
+
// a partial append. Offsets accumulate forever, so guard the running total.
|
|
55
|
+
const offset = this.#nextOffset;
|
|
56
|
+
const nextOffset = addSafeInt(offset, 1, "ReplayRing offset");
|
|
57
|
+
const entry = { offset, chunk };
|
|
58
|
+
if (this.#count < this.capacity) {
|
|
59
|
+
this.#buffer[(this.#head + this.#count) % this.capacity] = entry;
|
|
60
|
+
this.#count += 1;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// At capacity: overwrite the oldest slot and advance the head — O(1).
|
|
64
|
+
this.#buffer[this.#head] = entry;
|
|
65
|
+
this.#head = (this.#head + 1) % this.capacity;
|
|
66
|
+
}
|
|
67
|
+
this.#nextOffset = nextOffset;
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Return the retained tail from offset `from` (inclusive), for resume. `from`
|
|
72
|
+
* is clamped to what is retained:
|
|
73
|
+
* - `from <= firstOffset` → the whole retained window; `gap` is `true` when
|
|
74
|
+
* `from` is strictly before the oldest retained offset (evicted chunks).
|
|
75
|
+
* - `firstOffset < from <= nextOffset` → the exact suffix from `from`; no gap.
|
|
76
|
+
* - `from > nextOffset` → empty (the consumer is ahead of the stream); no gap.
|
|
77
|
+
*/
|
|
78
|
+
since(from) {
|
|
79
|
+
if (!isNonNegInt(from)) {
|
|
80
|
+
throw new RangeError(`since(from) requires a non-negative integer, got ${from}`);
|
|
81
|
+
}
|
|
82
|
+
const first = this.firstOffset;
|
|
83
|
+
if (first === undefined || from >= this.#nextOffset) {
|
|
84
|
+
// Nothing retained, or the consumer already has everything.
|
|
85
|
+
return { entries: [], gap: first !== undefined && from < first };
|
|
86
|
+
}
|
|
87
|
+
const gap = from < first;
|
|
88
|
+
const startOffset = gap ? first : from;
|
|
89
|
+
const startIndex = startOffset - first;
|
|
90
|
+
const entries = [];
|
|
91
|
+
for (let i = startIndex; i < this.#count; i += 1) {
|
|
92
|
+
const entry = this.#buffer[(this.#head + i) % this.capacity];
|
|
93
|
+
if (entry !== undefined) {
|
|
94
|
+
entries.push(entry);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { entries, gap };
|
|
98
|
+
}
|
|
99
|
+
/** Drop every retained chunk. The offset counter is NOT reset (offsets stay monotonic). */
|
|
100
|
+
clear() {
|
|
101
|
+
this.#buffer.fill(undefined);
|
|
102
|
+
this.#head = 0;
|
|
103
|
+
this.#count = 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-lane QoS scheduler — S5's outbound egress for one consumer socket.
|
|
3
|
+
*
|
|
4
|
+
* The channel carries three lanes in STRICT priority: control/facts >
|
|
5
|
+
* interactive > bulk (invariant #5). The relay data plane is high-volume `bulk`
|
|
6
|
+
* traffic; heartbeats, blackboard writes and relay control acks ride control /
|
|
7
|
+
* interactive. The load-bearing guarantee: a bulk-output storm must NEVER
|
|
8
|
+
* head-of-line-block a control or interactive frame.
|
|
9
|
+
*
|
|
10
|
+
* Two mechanisms enforce it:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Strict lane priority.** {@link QosScheduler.flush} drains all control,
|
|
13
|
+
* then all interactive, then bulk. Control/interactive are never gated, so a
|
|
14
|
+
* queued heartbeat is emitted ahead of any bulk backlog. Cross-lane order is
|
|
15
|
+
* DERIVED from S0's {@link compareFrameOrder} (asserted in the tests).
|
|
16
|
+
*
|
|
17
|
+
* 2. **Credit-based backpressure on the bulk lane.** A slow consumer grants
|
|
18
|
+
* credit for how many bulk frames it can accept; the scheduler emits bulk
|
|
19
|
+
* only while credit remains. With zero credit, bulk buffers (bounded — see
|
|
20
|
+
* `bulkCapacity`) while control/interactive still flow freely. The buffered
|
|
21
|
+
* bulk tail is safe to shed because the {@link ReplayRing} retains it for a
|
|
22
|
+
* later resume-from-offset.
|
|
23
|
+
*/
|
|
24
|
+
import { compareFrameOrder, lanePriority } from "../protocol/index.ts";
|
|
25
|
+
import type { Frame } from "../protocol/index.ts";
|
|
26
|
+
export interface QosSchedulerOptions {
|
|
27
|
+
/** Where drained frames are emitted (typically the connection's `send`). */
|
|
28
|
+
readonly sink: (frame: Frame) => void;
|
|
29
|
+
/** Initial bulk credit. Default 0 — bulk stays buffered until credit is granted. */
|
|
30
|
+
readonly credit?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Maximum buffered bulk frames before overflow sheds the oldest. Default 1024.
|
|
33
|
+
* Shedding is safe: the replay ring retains evicted chunks for resume. Control
|
|
34
|
+
* and interactive frames are never shed.
|
|
35
|
+
*/
|
|
36
|
+
readonly bulkCapacity?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare class QosScheduler {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(options: QosSchedulerOptions);
|
|
41
|
+
/** Remaining bulk credit. */
|
|
42
|
+
get credit(): number;
|
|
43
|
+
/** Frames buffered but not yet emitted (across all lanes). */
|
|
44
|
+
get pending(): number;
|
|
45
|
+
/** Bulk frames buffered awaiting credit. */
|
|
46
|
+
get pendingBulk(): number;
|
|
47
|
+
/** How many bulk frames have been shed on overflow over this scheduler's life. */
|
|
48
|
+
get shed(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Buffer a frame on its lane and drain what is now eligible. Control and
|
|
51
|
+
* interactive frames drain immediately (never credit-gated); a bulk frame
|
|
52
|
+
* drains only if credit is available, otherwise it waits (or sheds the oldest
|
|
53
|
+
* bulk frame if the bulk buffer is full).
|
|
54
|
+
*/
|
|
55
|
+
enqueue(frame: Frame): void;
|
|
56
|
+
/** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
|
|
57
|
+
grantCredit(n: number): void;
|
|
58
|
+
/**
|
|
59
|
+
* Drain eligible frames to the sink in strict lane priority: all control, then
|
|
60
|
+
* all interactive, then bulk up to the available credit. Control/interactive
|
|
61
|
+
* are never head-of-line-blocked by bulk backlog or exhausted credit.
|
|
62
|
+
*/
|
|
63
|
+
flush(): void;
|
|
64
|
+
/** Discard every buffered frame (e.g. on subscriber teardown). */
|
|
65
|
+
clear(): void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Re-exported from S0 so callers reason about lane ordering from one source
|
|
69
|
+
* rather than re-deriving priority. The scheduler's drain order (with unlimited
|
|
70
|
+
* credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
|
|
71
|
+
*/
|
|
72
|
+
export { compareFrameOrder, lanePriority };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-lane QoS scheduler — S5's outbound egress for one consumer socket.
|
|
3
|
+
*
|
|
4
|
+
* The channel carries three lanes in STRICT priority: control/facts >
|
|
5
|
+
* interactive > bulk (invariant #5). The relay data plane is high-volume `bulk`
|
|
6
|
+
* traffic; heartbeats, blackboard writes and relay control acks ride control /
|
|
7
|
+
* interactive. The load-bearing guarantee: a bulk-output storm must NEVER
|
|
8
|
+
* head-of-line-block a control or interactive frame.
|
|
9
|
+
*
|
|
10
|
+
* Two mechanisms enforce it:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Strict lane priority.** {@link QosScheduler.flush} drains all control,
|
|
13
|
+
* then all interactive, then bulk. Control/interactive are never gated, so a
|
|
14
|
+
* queued heartbeat is emitted ahead of any bulk backlog. Cross-lane order is
|
|
15
|
+
* DERIVED from S0's {@link compareFrameOrder} (asserted in the tests).
|
|
16
|
+
*
|
|
17
|
+
* 2. **Credit-based backpressure on the bulk lane.** A slow consumer grants
|
|
18
|
+
* credit for how many bulk frames it can accept; the scheduler emits bulk
|
|
19
|
+
* only while credit remains. With zero credit, bulk buffers (bounded — see
|
|
20
|
+
* `bulkCapacity`) while control/interactive still flow freely. The buffered
|
|
21
|
+
* bulk tail is safe to shed because the {@link ReplayRing} retains it for a
|
|
22
|
+
* later resume-from-offset.
|
|
23
|
+
*/
|
|
24
|
+
import { compareFrameOrder, lanePriority } from "../protocol/index.js";
|
|
25
|
+
import { addSafeInt, isNonNegInt, isPosInt } from "./validate.js";
|
|
26
|
+
const DEFAULT_BULK_CAPACITY = 1024;
|
|
27
|
+
export class QosScheduler {
|
|
28
|
+
#sink;
|
|
29
|
+
#bulkCapacity;
|
|
30
|
+
#control = [];
|
|
31
|
+
#interactive = [];
|
|
32
|
+
// The bulk lane is a fixed-size circular buffer: both overflow shedding and
|
|
33
|
+
// credit-gated draining advance a head index in O(1)/O(k) rather than
|
|
34
|
+
// Array.shift() (O(n) per frame). Under the sustained "bulk storm" this
|
|
35
|
+
// scheduler exists to absorb, shift() would degrade to O(n²). Same eviction
|
|
36
|
+
// discipline as ReplayRing: overwrite the oldest slot, advance the head.
|
|
37
|
+
#bulk = [];
|
|
38
|
+
#bulkHead = 0;
|
|
39
|
+
#bulkCount = 0;
|
|
40
|
+
#credit;
|
|
41
|
+
#shed = 0;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
if (options.credit !== undefined && !isNonNegInt(options.credit)) {
|
|
44
|
+
throw new RangeError(`QosScheduler credit must be a non-negative integer, got ${options.credit}`);
|
|
45
|
+
}
|
|
46
|
+
const bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
|
|
47
|
+
if (!isPosInt(bulkCapacity)) {
|
|
48
|
+
throw new RangeError(`QosScheduler bulkCapacity must be a positive integer, got ${bulkCapacity}`);
|
|
49
|
+
}
|
|
50
|
+
this.#sink = options.sink;
|
|
51
|
+
this.#credit = options.credit ?? 0;
|
|
52
|
+
this.#bulkCapacity = bulkCapacity;
|
|
53
|
+
this.#bulk.length = bulkCapacity;
|
|
54
|
+
}
|
|
55
|
+
/** Remaining bulk credit. */
|
|
56
|
+
get credit() {
|
|
57
|
+
return this.#credit;
|
|
58
|
+
}
|
|
59
|
+
/** Frames buffered but not yet emitted (across all lanes). */
|
|
60
|
+
get pending() {
|
|
61
|
+
return this.#control.length + this.#interactive.length + this.#bulkCount;
|
|
62
|
+
}
|
|
63
|
+
/** Bulk frames buffered awaiting credit. */
|
|
64
|
+
get pendingBulk() {
|
|
65
|
+
return this.#bulkCount;
|
|
66
|
+
}
|
|
67
|
+
/** How many bulk frames have been shed on overflow over this scheduler's life. */
|
|
68
|
+
get shed() {
|
|
69
|
+
return this.#shed;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Buffer a frame on its lane and drain what is now eligible. Control and
|
|
73
|
+
* interactive frames drain immediately (never credit-gated); a bulk frame
|
|
74
|
+
* drains only if credit is available, otherwise it waits (or sheds the oldest
|
|
75
|
+
* bulk frame if the bulk buffer is full).
|
|
76
|
+
*/
|
|
77
|
+
enqueue(frame) {
|
|
78
|
+
switch (frame.lane) {
|
|
79
|
+
case "control":
|
|
80
|
+
this.#control.push(frame);
|
|
81
|
+
break;
|
|
82
|
+
case "interactive":
|
|
83
|
+
this.#interactive.push(frame);
|
|
84
|
+
break;
|
|
85
|
+
case "bulk":
|
|
86
|
+
this.#pushBulk(frame);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
this.flush();
|
|
90
|
+
}
|
|
91
|
+
/** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
|
|
92
|
+
grantCredit(n) {
|
|
93
|
+
if (!isNonNegInt(n)) {
|
|
94
|
+
throw new RangeError(`grantCredit requires a non-negative integer, got ${n}`);
|
|
95
|
+
}
|
|
96
|
+
this.#credit = addSafeInt(this.#credit, n, "QosScheduler credit");
|
|
97
|
+
this.flush();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Drain eligible frames to the sink in strict lane priority: all control, then
|
|
101
|
+
* all interactive, then bulk up to the available credit. Control/interactive
|
|
102
|
+
* are never head-of-line-blocked by bulk backlog or exhausted credit.
|
|
103
|
+
*/
|
|
104
|
+
flush() {
|
|
105
|
+
// Remove eligible frames up-front, then emit. Removing before the sink call
|
|
106
|
+
// preserves "remove before sink" semantics (a re-entrant flush() from within
|
|
107
|
+
// sink() cannot re-emit an already-taken frame) and keeps the drain O(k) in
|
|
108
|
+
// the number of frames actually emitted, not O(n²): repeated Array.shift()
|
|
109
|
+
// is O(n) per element and degrades on a large bulk burst — exactly the storm
|
|
110
|
+
// this scheduler exists to absorb.
|
|
111
|
+
//
|
|
112
|
+
// Guard each splice with a length check: enqueue() calls flush() on every
|
|
113
|
+
// frame, so in the bulk-storm / zero-credit case the control and interactive
|
|
114
|
+
// lanes are usually empty. splice(0) on an empty array still allocates a new
|
|
115
|
+
// (empty) array, so skipping it keeps the empty-lane hot path allocation-free.
|
|
116
|
+
if (this.#control.length > 0) {
|
|
117
|
+
for (const frame of this.#control.splice(0)) {
|
|
118
|
+
this.#sink(frame);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (this.#interactive.length > 0) {
|
|
122
|
+
for (const frame of this.#interactive.splice(0)) {
|
|
123
|
+
this.#sink(frame);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Drain the bulk lane one frame at a time rather than removing the whole
|
|
127
|
+
// credit-worth up-front. Removing the frame (and spending its credit) only
|
|
128
|
+
// as it is emitted keeps "remove before sink" — a re-entrant flush() from
|
|
129
|
+
// within sink() still cannot re-emit an in-flight frame — while making the
|
|
130
|
+
// drain robust to a throwing sink: if #sink() throws mid-drain, the un-taken
|
|
131
|
+
// tail stays buffered in the ring and its credit is untouched, so a transient
|
|
132
|
+
// send failure sheds at most the one frame it was called with instead of
|
|
133
|
+
// silently dropping the rest of the backlog and burning their credit.
|
|
134
|
+
while (this.#credit > 0 && this.#bulkCount > 0) {
|
|
135
|
+
const bulkFrame = this.#shiftBulk();
|
|
136
|
+
if (bulkFrame === undefined) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
this.#credit -= 1;
|
|
140
|
+
this.#sink(bulkFrame);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Discard every buffered frame (e.g. on subscriber teardown). */
|
|
144
|
+
clear() {
|
|
145
|
+
this.#control.length = 0;
|
|
146
|
+
this.#interactive.length = 0;
|
|
147
|
+
this.#bulk.fill(undefined);
|
|
148
|
+
this.#bulkHead = 0;
|
|
149
|
+
this.#bulkCount = 0;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Append a bulk frame to the circular buffer, shedding the oldest buffered
|
|
153
|
+
* bulk frame first when already at capacity (safe: the ReplayRing retains it
|
|
154
|
+
* for resume). All O(1) — no Array.shift().
|
|
155
|
+
*/
|
|
156
|
+
#pushBulk(frame) {
|
|
157
|
+
if (this.#bulkCount === this.#bulkCapacity) {
|
|
158
|
+
this.#bulk[this.#bulkHead] = undefined;
|
|
159
|
+
this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
|
|
160
|
+
this.#bulkCount -= 1;
|
|
161
|
+
this.#shed += 1;
|
|
162
|
+
}
|
|
163
|
+
this.#bulk[(this.#bulkHead + this.#bulkCount) % this.#bulkCapacity] = frame;
|
|
164
|
+
this.#bulkCount += 1;
|
|
165
|
+
}
|
|
166
|
+
/** Remove and return the oldest bulk frame from the circular buffer (O(1)). */
|
|
167
|
+
#shiftBulk() {
|
|
168
|
+
const frame = this.#bulk[this.#bulkHead];
|
|
169
|
+
this.#bulk[this.#bulkHead] = undefined;
|
|
170
|
+
this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
|
|
171
|
+
this.#bulkCount -= 1;
|
|
172
|
+
return frame;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Re-exported from S0 so callers reason about lane ordering from one source
|
|
177
|
+
* rather than re-deriving priority. The scheduler's drain order (with unlimited
|
|
178
|
+
* credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
|
|
179
|
+
*/
|
|
180
|
+
export { compareFrameOrder, lanePriority };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical safe-integer validators for the relay package.
|
|
3
|
+
*
|
|
4
|
+
* Every integer the relay handles — replay offsets, ring/bulk capacities,
|
|
5
|
+
* credit budgets, incarnation generations, and untrusted inbound protocol
|
|
6
|
+
* fields (`from`, `credit`, `incarnation`) — is accumulated, decremented,
|
|
7
|
+
* compared, or round-tripped through JSON. A value beyond
|
|
8
|
+
* `Number.MAX_SAFE_INTEGER` cannot survive that arithmetic or JSON round-trip
|
|
9
|
+
* without silent precision loss, which would corrupt offset/credit semantics.
|
|
10
|
+
* `Number.isSafeInteger` (not `Number.isInteger`) is therefore the single
|
|
11
|
+
* correct predicate. Deriving every integer check from these two guards keeps
|
|
12
|
+
* that rule in one place instead of scattering `Number.isInteger(x) || x < 0`
|
|
13
|
+
* across the package, where one site could drift from the rest.
|
|
14
|
+
*/
|
|
15
|
+
/** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
|
|
16
|
+
export declare function isNonNegInt(value: unknown): value is number;
|
|
17
|
+
/** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
|
|
18
|
+
export declare function isPosInt(value: unknown): value is number;
|
|
19
|
+
/**
|
|
20
|
+
* Add a delta to a running counter, failing fast if the sum would leave the
|
|
21
|
+
* safe-integer range. Validating each individual input with the guards above is
|
|
22
|
+
* necessary but not sufficient: relay counters (bulk credit, replay offsets)
|
|
23
|
+
* accumulate across many operations, so repeated additions of individually-safe
|
|
24
|
+
* values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
|
|
25
|
+
* it silently loses precision and corrupts credit/offset semantics. Every
|
|
26
|
+
* counter that accumulates derives its overflow check from here rather than
|
|
27
|
+
* re-implementing the boundary. `label` names the counter for the error.
|
|
28
|
+
*/
|
|
29
|
+
export declare function addSafeInt(current: number, delta: number, label: string): number;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical safe-integer validators for the relay package.
|
|
3
|
+
*
|
|
4
|
+
* Every integer the relay handles — replay offsets, ring/bulk capacities,
|
|
5
|
+
* credit budgets, incarnation generations, and untrusted inbound protocol
|
|
6
|
+
* fields (`from`, `credit`, `incarnation`) — is accumulated, decremented,
|
|
7
|
+
* compared, or round-tripped through JSON. A value beyond
|
|
8
|
+
* `Number.MAX_SAFE_INTEGER` cannot survive that arithmetic or JSON round-trip
|
|
9
|
+
* without silent precision loss, which would corrupt offset/credit semantics.
|
|
10
|
+
* `Number.isSafeInteger` (not `Number.isInteger`) is therefore the single
|
|
11
|
+
* correct predicate. Deriving every integer check from these two guards keeps
|
|
12
|
+
* that rule in one place instead of scattering `Number.isInteger(x) || x < 0`
|
|
13
|
+
* across the package, where one site could drift from the rest.
|
|
14
|
+
*/
|
|
15
|
+
/** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
|
|
16
|
+
export function isNonNegInt(value) {
|
|
17
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
18
|
+
}
|
|
19
|
+
/** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
|
|
20
|
+
export function isPosInt(value) {
|
|
21
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Add a delta to a running counter, failing fast if the sum would leave the
|
|
25
|
+
* safe-integer range. Validating each individual input with the guards above is
|
|
26
|
+
* necessary but not sufficient: relay counters (bulk credit, replay offsets)
|
|
27
|
+
* accumulate across many operations, so repeated additions of individually-safe
|
|
28
|
+
* values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
|
|
29
|
+
* it silently loses precision and corrupts credit/offset semantics. Every
|
|
30
|
+
* counter that accumulates derives its overflow check from here rather than
|
|
31
|
+
* re-implementing the boundary. `label` names the counter for the error.
|
|
32
|
+
*/
|
|
33
|
+
export function addSafeInt(current, delta, label) {
|
|
34
|
+
const sum = current + delta;
|
|
35
|
+
if (!Number.isSafeInteger(sum)) {
|
|
36
|
+
throw new RangeError(`${label} overflowed the safe-integer range (${current} + ${delta} exceeds Number.MAX_SAFE_INTEGER)`);
|
|
37
|
+
}
|
|
38
|
+
return sum;
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-transcript — the transcript store for the Nano agentic
|
|
3
|
+
* protocol (ADR 0056, slice S6).
|
|
4
|
+
*
|
|
5
|
+
* Retention-by-lifecycle over the app DataLayer/SQLite ({@link TranscriptStore}):
|
|
6
|
+
* an ephemeral run flushes the S5 relay ring to a durable, readable transcript on
|
|
7
|
+
* job completion; a long-lived stream retains its chunks so a reconnecting
|
|
8
|
+
* consumer can resume-from-offset (reattach). It builds on the S5 relay's
|
|
9
|
+
* resume-from-offset semantics (`@nanobpm/agentic-relay`) and stores through the
|
|
10
|
+
* app DataLayer only — nothing rides the Camunda-8 engine or its transport.
|
|
11
|
+
*
|
|
12
|
+
* The new DB schema ships as the forward-only, additive migration
|
|
13
|
+
* `db/migrations/002_agentic_transcript.sql`, mirrored by {@link TRANSCRIPT_SCHEMA_SQL}
|
|
14
|
+
* and kept in lockstep by a drift-guard test.
|
|
15
|
+
*/
|
|
16
|
+
export { TranscriptStore, TranscriptCorruptionError, TranscriptLifecycleError, systemClock } from "./store.ts";
|
|
17
|
+
export type { Clock, SqliteDb, TranscriptChunk, TranscriptLifecycle, TranscriptRing, TranscriptSlice, TranscriptStatus, TranscriptStoreOptions, TranscriptStream, } from "./store.ts";
|
|
18
|
+
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.ts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nanobpm/agentic-transcript — the transcript store for the Nano agentic
|
|
3
|
+
* protocol (ADR 0056, slice S6).
|
|
4
|
+
*
|
|
5
|
+
* Retention-by-lifecycle over the app DataLayer/SQLite ({@link TranscriptStore}):
|
|
6
|
+
* an ephemeral run flushes the S5 relay ring to a durable, readable transcript on
|
|
7
|
+
* job completion; a long-lived stream retains its chunks so a reconnecting
|
|
8
|
+
* consumer can resume-from-offset (reattach). It builds on the S5 relay's
|
|
9
|
+
* resume-from-offset semantics (`@nanobpm/agentic-relay`) and stores through the
|
|
10
|
+
* app DataLayer only — nothing rides the Camunda-8 engine or its transport.
|
|
11
|
+
*
|
|
12
|
+
* The new DB schema ships as the forward-only, additive migration
|
|
13
|
+
* `db/migrations/002_agentic_transcript.sql`, mirrored by {@link TRANSCRIPT_SCHEMA_SQL}
|
|
14
|
+
* and kept in lockstep by a drift-guard test.
|
|
15
|
+
*/
|
|
16
|
+
export { TranscriptStore, TranscriptCorruptionError, TranscriptLifecycleError, systemClock } from "./store.js";
|
|
17
|
+
export { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, } from "./schema.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical transcript-store schema.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth the {@link TranscriptStore} applies
|
|
5
|
+
* through {@link TranscriptStore.ensureSchema}. The very same statements are
|
|
6
|
+
* mirrored in the app-boot migration `db/migrations/002_agentic_transcript.sql`
|
|
7
|
+
* (applied by the DataLayer migration runner). To keep those two application
|
|
8
|
+
* paths from drifting, `schema.test.ts` normalises both and asserts they are
|
|
9
|
+
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
|
+
* production/boot mismatch.
|
|
11
|
+
*
|
|
12
|
+
* Two tables back the store:
|
|
13
|
+
* - `agentic_transcript_stream` — one row per relay stream: its retention
|
|
14
|
+
* lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
|
|
15
|
+
* and the offset window (`first_offset` … `next_offset`) currently retained.
|
|
16
|
+
* - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
|
|
17
|
+
* so a flush/append is idempotent and reattach can slice from any offset.
|
|
18
|
+
*
|
|
19
|
+
* `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
|
|
20
|
+
* the column is named to avoid quoting it in every statement.
|
|
21
|
+
*/
|
|
22
|
+
/** The per-stream metadata table name. */
|
|
23
|
+
export declare const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
24
|
+
/** The durable per-chunk table name. */
|
|
25
|
+
export declare const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
26
|
+
/**
|
|
27
|
+
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
28
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
29
|
+
* it). Chunks are immutable once written — retention drops whole windows/streams,
|
|
30
|
+
* it never rewrites a chunk.
|
|
31
|
+
*/
|
|
32
|
+
export declare const TRANSCRIPT_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_transcript_stream (\n stream TEXT PRIMARY KEY,\n lifecycle TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'open',\n created_at TEXT NOT NULL,\n completed_at TEXT,\n first_offset INTEGER,\n next_offset INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS agentic_transcript_chunk (\n stream TEXT NOT NULL,\n chunk_offset INTEGER NOT NULL,\n chunk TEXT NOT NULL,\n appended_at TEXT NOT NULL,\n PRIMARY KEY (stream, chunk_offset)\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_transcript_stream_retention ON agentic_transcript_stream (lifecycle, status, completed_at);";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical transcript-store schema.
|
|
3
|
+
*
|
|
4
|
+
* The DDL here is the single source of truth the {@link TranscriptStore} applies
|
|
5
|
+
* through {@link TranscriptStore.ensureSchema}. The very same statements are
|
|
6
|
+
* mirrored in the app-boot migration `db/migrations/002_agentic_transcript.sql`
|
|
7
|
+
* (applied by the DataLayer migration runner). To keep those two application
|
|
8
|
+
* paths from drifting, `schema.test.ts` normalises both and asserts they are
|
|
9
|
+
* statement-for-statement identical — divergence is a red test, not a silent
|
|
10
|
+
* production/boot mismatch.
|
|
11
|
+
*
|
|
12
|
+
* Two tables back the store:
|
|
13
|
+
* - `agentic_transcript_stream` — one row per relay stream: its retention
|
|
14
|
+
* lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
|
|
15
|
+
* and the offset window (`first_offset` … `next_offset`) currently retained.
|
|
16
|
+
* - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
|
|
17
|
+
* so a flush/append is idempotent and reattach can slice from any offset.
|
|
18
|
+
*
|
|
19
|
+
* `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
|
|
20
|
+
* the column is named to avoid quoting it in every statement.
|
|
21
|
+
*/
|
|
22
|
+
/** The per-stream metadata table name. */
|
|
23
|
+
export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
24
|
+
/** The durable per-chunk table name. */
|
|
25
|
+
export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
26
|
+
/**
|
|
27
|
+
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
28
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
29
|
+
* it). Chunks are immutable once written — retention drops whole windows/streams,
|
|
30
|
+
* it never rewrites a chunk.
|
|
31
|
+
*/
|
|
32
|
+
export const TRANSCRIPT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_STREAM_TABLE} (
|
|
33
|
+
stream TEXT PRIMARY KEY,
|
|
34
|
+
lifecycle TEXT NOT NULL,
|
|
35
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
completed_at TEXT,
|
|
38
|
+
first_offset INTEGER,
|
|
39
|
+
next_offset INTEGER NOT NULL DEFAULT 0
|
|
40
|
+
);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
|
|
42
|
+
stream TEXT NOT NULL,
|
|
43
|
+
chunk_offset INTEGER NOT NULL,
|
|
44
|
+
chunk TEXT NOT NULL,
|
|
45
|
+
appended_at TEXT NOT NULL,
|
|
46
|
+
PRIMARY KEY (stream, chunk_offset)
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
|