@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,208 @@
|
|
|
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
|
+
import { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
|
|
27
|
+
|
|
28
|
+
export interface QosSchedulerOptions {
|
|
29
|
+
/** Where drained frames are emitted (typically the connection's `send`). */
|
|
30
|
+
readonly sink: (frame: Frame) => void;
|
|
31
|
+
/** Initial bulk credit. Default 0 — bulk stays buffered until credit is granted. */
|
|
32
|
+
readonly credit?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Maximum buffered bulk frames before overflow sheds the oldest. Default 1024.
|
|
35
|
+
* Shedding is safe: the replay ring retains evicted chunks for resume. Control
|
|
36
|
+
* and interactive frames are never shed.
|
|
37
|
+
*/
|
|
38
|
+
readonly bulkCapacity?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_BULK_CAPACITY = 1024;
|
|
42
|
+
|
|
43
|
+
export class QosScheduler {
|
|
44
|
+
readonly #sink: (frame: Frame) => void;
|
|
45
|
+
readonly #bulkCapacity: number;
|
|
46
|
+
readonly #control: Frame[] = [];
|
|
47
|
+
readonly #interactive: Frame[] = [];
|
|
48
|
+
// The bulk lane is a fixed-size circular buffer: both overflow shedding and
|
|
49
|
+
// credit-gated draining advance a head index in O(1)/O(k) rather than
|
|
50
|
+
// Array.shift() (O(n) per frame). Under the sustained "bulk storm" this
|
|
51
|
+
// scheduler exists to absorb, shift() would degrade to O(n²). Same eviction
|
|
52
|
+
// discipline as ReplayRing: overwrite the oldest slot, advance the head.
|
|
53
|
+
readonly #bulk: (Frame | undefined)[] = [];
|
|
54
|
+
#bulkHead = 0;
|
|
55
|
+
#bulkCount = 0;
|
|
56
|
+
#credit: number;
|
|
57
|
+
#shed = 0;
|
|
58
|
+
|
|
59
|
+
constructor(options: QosSchedulerOptions) {
|
|
60
|
+
if (options.credit !== undefined && !isNonNegInt(options.credit)) {
|
|
61
|
+
throw new RangeError(`QosScheduler credit must be a non-negative integer, got ${options.credit}`);
|
|
62
|
+
}
|
|
63
|
+
const bulkCapacity = options.bulkCapacity ?? DEFAULT_BULK_CAPACITY;
|
|
64
|
+
if (!isPosInt(bulkCapacity)) {
|
|
65
|
+
throw new RangeError(`QosScheduler bulkCapacity must be a positive integer, got ${bulkCapacity}`);
|
|
66
|
+
}
|
|
67
|
+
this.#sink = options.sink;
|
|
68
|
+
this.#credit = options.credit ?? 0;
|
|
69
|
+
this.#bulkCapacity = bulkCapacity;
|
|
70
|
+
this.#bulk.length = bulkCapacity;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Remaining bulk credit. */
|
|
74
|
+
get credit(): number {
|
|
75
|
+
return this.#credit;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Frames buffered but not yet emitted (across all lanes). */
|
|
79
|
+
get pending(): number {
|
|
80
|
+
return this.#control.length + this.#interactive.length + this.#bulkCount;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bulk frames buffered awaiting credit. */
|
|
84
|
+
get pendingBulk(): number {
|
|
85
|
+
return this.#bulkCount;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many bulk frames have been shed on overflow over this scheduler's life. */
|
|
89
|
+
get shed(): number {
|
|
90
|
+
return this.#shed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Buffer a frame on its lane and drain what is now eligible. Control and
|
|
95
|
+
* interactive frames drain immediately (never credit-gated); a bulk frame
|
|
96
|
+
* drains only if credit is available, otherwise it waits (or sheds the oldest
|
|
97
|
+
* bulk frame if the bulk buffer is full).
|
|
98
|
+
*/
|
|
99
|
+
enqueue(frame: Frame): void {
|
|
100
|
+
switch (frame.lane) {
|
|
101
|
+
case "control":
|
|
102
|
+
this.#control.push(frame);
|
|
103
|
+
break;
|
|
104
|
+
case "interactive":
|
|
105
|
+
this.#interactive.push(frame);
|
|
106
|
+
break;
|
|
107
|
+
case "bulk":
|
|
108
|
+
this.#pushBulk(frame);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
this.flush();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Grant `n` more bulk credits, then drain any bulk frames that credit now allows. */
|
|
115
|
+
grantCredit(n: number): void {
|
|
116
|
+
if (!isNonNegInt(n)) {
|
|
117
|
+
throw new RangeError(`grantCredit requires a non-negative integer, got ${n}`);
|
|
118
|
+
}
|
|
119
|
+
this.#credit = addSafeInt(this.#credit, n, "QosScheduler credit");
|
|
120
|
+
this.flush();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drain eligible frames to the sink in strict lane priority: all control, then
|
|
125
|
+
* all interactive, then bulk up to the available credit. Control/interactive
|
|
126
|
+
* are never head-of-line-blocked by bulk backlog or exhausted credit.
|
|
127
|
+
*/
|
|
128
|
+
flush(): void {
|
|
129
|
+
// Remove eligible frames up-front, then emit. Removing before the sink call
|
|
130
|
+
// preserves "remove before sink" semantics (a re-entrant flush() from within
|
|
131
|
+
// sink() cannot re-emit an already-taken frame) and keeps the drain O(k) in
|
|
132
|
+
// the number of frames actually emitted, not O(n²): repeated Array.shift()
|
|
133
|
+
// is O(n) per element and degrades on a large bulk burst — exactly the storm
|
|
134
|
+
// this scheduler exists to absorb.
|
|
135
|
+
//
|
|
136
|
+
// Guard each splice with a length check: enqueue() calls flush() on every
|
|
137
|
+
// frame, so in the bulk-storm / zero-credit case the control and interactive
|
|
138
|
+
// lanes are usually empty. splice(0) on an empty array still allocates a new
|
|
139
|
+
// (empty) array, so skipping it keeps the empty-lane hot path allocation-free.
|
|
140
|
+
if (this.#control.length > 0) {
|
|
141
|
+
for (const frame of this.#control.splice(0)) {
|
|
142
|
+
this.#sink(frame);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (this.#interactive.length > 0) {
|
|
146
|
+
for (const frame of this.#interactive.splice(0)) {
|
|
147
|
+
this.#sink(frame);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Drain the bulk lane one frame at a time rather than removing the whole
|
|
151
|
+
// credit-worth up-front. Removing the frame (and spending its credit) only
|
|
152
|
+
// as it is emitted keeps "remove before sink" — a re-entrant flush() from
|
|
153
|
+
// within sink() still cannot re-emit an in-flight frame — while making the
|
|
154
|
+
// drain robust to a throwing sink: if #sink() throws mid-drain, the un-taken
|
|
155
|
+
// tail stays buffered in the ring and its credit is untouched, so a transient
|
|
156
|
+
// send failure sheds at most the one frame it was called with instead of
|
|
157
|
+
// silently dropping the rest of the backlog and burning their credit.
|
|
158
|
+
while (this.#credit > 0 && this.#bulkCount > 0) {
|
|
159
|
+
const bulkFrame = this.#shiftBulk();
|
|
160
|
+
if (bulkFrame === undefined) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
this.#credit -= 1;
|
|
164
|
+
this.#sink(bulkFrame);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Discard every buffered frame (e.g. on subscriber teardown). */
|
|
169
|
+
clear(): void {
|
|
170
|
+
this.#control.length = 0;
|
|
171
|
+
this.#interactive.length = 0;
|
|
172
|
+
this.#bulk.fill(undefined);
|
|
173
|
+
this.#bulkHead = 0;
|
|
174
|
+
this.#bulkCount = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Append a bulk frame to the circular buffer, shedding the oldest buffered
|
|
179
|
+
* bulk frame first when already at capacity (safe: the ReplayRing retains it
|
|
180
|
+
* for resume). All O(1) — no Array.shift().
|
|
181
|
+
*/
|
|
182
|
+
#pushBulk(frame: Frame): void {
|
|
183
|
+
if (this.#bulkCount === this.#bulkCapacity) {
|
|
184
|
+
this.#bulk[this.#bulkHead] = undefined;
|
|
185
|
+
this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
|
|
186
|
+
this.#bulkCount -= 1;
|
|
187
|
+
this.#shed += 1;
|
|
188
|
+
}
|
|
189
|
+
this.#bulk[(this.#bulkHead + this.#bulkCount) % this.#bulkCapacity] = frame;
|
|
190
|
+
this.#bulkCount += 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Remove and return the oldest bulk frame from the circular buffer (O(1)). */
|
|
194
|
+
#shiftBulk(): Frame | undefined {
|
|
195
|
+
const frame = this.#bulk[this.#bulkHead];
|
|
196
|
+
this.#bulk[this.#bulkHead] = undefined;
|
|
197
|
+
this.#bulkHead = (this.#bulkHead + 1) % this.#bulkCapacity;
|
|
198
|
+
this.#bulkCount -= 1;
|
|
199
|
+
return frame;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Re-exported from S0 so callers reason about lane ordering from one source
|
|
205
|
+
* rather than re-deriving priority. The scheduler's drain order (with unlimited
|
|
206
|
+
* credit) is asserted equal to a stable sort by {@link compareFrameOrder}.
|
|
207
|
+
*/
|
|
208
|
+
export { compareFrameOrder, lanePriority };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { addSafeInt, isNonNegInt, isPosInt } from "./validate.ts";
|
|
4
|
+
|
|
5
|
+
// Defect-class guard: relay integers are accumulated/decremented and round-trip
|
|
6
|
+
// through JSON, so a value beyond Number.MAX_SAFE_INTEGER would silently lose
|
|
7
|
+
// precision. The canonical guards must reject unsafe integers, not merely
|
|
8
|
+
// non-integers — this is the single rule every validation site derives from.
|
|
9
|
+
const UNSAFE = Number.MAX_SAFE_INTEGER + 1;
|
|
10
|
+
|
|
11
|
+
test("isNonNegInt accepts non-negative safe integers", () => {
|
|
12
|
+
for (const v of [0, 1, 2, 100, Number.MAX_SAFE_INTEGER]) {
|
|
13
|
+
assert.equal(isNonNegInt(v), true, `${v} should be a non-negative safe integer`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("isNonNegInt rejects unsafe, negative, fractional, and non-number values", () => {
|
|
18
|
+
for (const v of [UNSAFE, -1, -UNSAFE, 1.5, Number.NaN, Number.POSITIVE_INFINITY, "1", null, undefined, {}]) {
|
|
19
|
+
assert.equal(isNonNegInt(v), false, `${String(v)} should be rejected`);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("isPosInt accepts positive safe integers only", () => {
|
|
24
|
+
for (const v of [1, 2, 100, Number.MAX_SAFE_INTEGER]) {
|
|
25
|
+
assert.equal(isPosInt(v), true, `${v} should be a positive safe integer`);
|
|
26
|
+
}
|
|
27
|
+
for (const v of [0, UNSAFE, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, "1", null, undefined]) {
|
|
28
|
+
assert.equal(isPosInt(v), false, `${String(v)} should be rejected`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("addSafeInt sums non-negative safe integers", () => {
|
|
33
|
+
assert.equal(addSafeInt(0, 1, "x"), 1);
|
|
34
|
+
assert.equal(addSafeInt(5, 7, "x"), 12);
|
|
35
|
+
assert.equal(addSafeInt(Number.MAX_SAFE_INTEGER - 1, 1, "x"), Number.MAX_SAFE_INTEGER);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("addSafeInt fails fast when accumulation would leave the safe-integer range", () => {
|
|
39
|
+
// Both operands are individually safe, but their sum is not — a value that
|
|
40
|
+
// would silently lose precision under further arithmetic/JSON round-trips.
|
|
41
|
+
assert.throws(() => addSafeInt(Number.MAX_SAFE_INTEGER, 1, "credit"), RangeError);
|
|
42
|
+
assert.throws(() => addSafeInt(Number.MAX_SAFE_INTEGER - 1, 2, "offset"), RangeError);
|
|
43
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
|
|
16
|
+
/** A non-negative safe integer: `0, 1, … Number.MAX_SAFE_INTEGER`. */
|
|
17
|
+
export function isNonNegInt(value: unknown): value is number {
|
|
18
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A positive safe integer: `1, 2, … Number.MAX_SAFE_INTEGER`. */
|
|
22
|
+
export function isPosInt(value: unknown): value is number {
|
|
23
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Add a delta to a running counter, failing fast if the sum would leave the
|
|
28
|
+
* safe-integer range. Validating each individual input with the guards above is
|
|
29
|
+
* necessary but not sufficient: relay counters (bulk credit, replay offsets)
|
|
30
|
+
* accumulate across many operations, so repeated additions of individually-safe
|
|
31
|
+
* values can still push the running total past `Number.MAX_SAFE_INTEGER`, where
|
|
32
|
+
* it silently loses precision and corrupts credit/offset semantics. Every
|
|
33
|
+
* counter that accumulates derives its overflow check from here rather than
|
|
34
|
+
* re-implementing the boundary. `label` names the counter for the error.
|
|
35
|
+
*/
|
|
36
|
+
export function addSafeInt(current: number, delta: number, label: string): number {
|
|
37
|
+
const sum = current + delta;
|
|
38
|
+
if (!Number.isSafeInteger(sum)) {
|
|
39
|
+
throw new RangeError(
|
|
40
|
+
`${label} overflowed the safe-integer range (${current} + ${delta} exceeds Number.MAX_SAFE_INTEGER)`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return sum;
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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 {
|
|
18
|
+
Clock,
|
|
19
|
+
SqliteDb,
|
|
20
|
+
TranscriptChunk,
|
|
21
|
+
TranscriptLifecycle,
|
|
22
|
+
TranscriptRing,
|
|
23
|
+
TranscriptSlice,
|
|
24
|
+
TranscriptStatus,
|
|
25
|
+
TranscriptStoreOptions,
|
|
26
|
+
TranscriptStream,
|
|
27
|
+
} from "./store.ts";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
TRANSCRIPT_CHUNK_TABLE,
|
|
31
|
+
TRANSCRIPT_SCHEMA_SQL,
|
|
32
|
+
TRANSCRIPT_STREAM_TABLE,
|
|
33
|
+
} from "./schema.ts";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, test } from "node:test";
|
|
3
|
+
// Import the real S5 relay ring from source so this package's tests need no
|
|
4
|
+
// prebuilt relay dist. The store flushes exactly this resume-from-offset source.
|
|
5
|
+
import { ReplayRing } from "../relay/index.ts";
|
|
6
|
+
import { TranscriptStore } from "./store.ts";
|
|
7
|
+
import { openTestDb, type TestDb } from "./test-db.ts";
|
|
8
|
+
|
|
9
|
+
let db: TestDb;
|
|
10
|
+
afterEach(() => db.close());
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
db = openTestDb();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("acceptance: an ephemeral run flushes the S5 ring to a durable, readable transcript on completion", () => {
|
|
16
|
+
const store = new TranscriptStore(db);
|
|
17
|
+
store.ensureSchema();
|
|
18
|
+
|
|
19
|
+
// A short ephemeral run produces terminal output into an S5 replay ring.
|
|
20
|
+
const ring = new ReplayRing({ capacity: 1024 });
|
|
21
|
+
const produced = ["$ npm test\n", "ok 1 - boots\n", "ok 2 - flushes\n", "# pass 2\n"];
|
|
22
|
+
for (const line of produced) ring.append(line);
|
|
23
|
+
|
|
24
|
+
// On job completion the ring is flushed to a durable transcript.
|
|
25
|
+
const flushed = store.flush("run-42", ring, "ephemeral");
|
|
26
|
+
assert.equal(flushed, produced.length);
|
|
27
|
+
|
|
28
|
+
const meta = store.get("run-42");
|
|
29
|
+
assert.equal(meta?.lifecycle, "ephemeral");
|
|
30
|
+
assert.equal(meta?.status, "completed", "an ephemeral flush completes the transcript");
|
|
31
|
+
assert.equal(meta?.nextOffset, ring.nextOffset);
|
|
32
|
+
|
|
33
|
+
// The transcript is durable and readable: full, in order, reconstructable.
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
store.read("run-42").map((c) => c.chunk),
|
|
36
|
+
produced,
|
|
37
|
+
);
|
|
38
|
+
// Durability across a fresh store handle over the same DB (no in-memory state).
|
|
39
|
+
const reopened = new TranscriptStore(db);
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
reopened.read("run-42").map((c) => c.chunk).join(""),
|
|
42
|
+
produced.join(""),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("acceptance: a long-lived stream reattaches from offset across a consumer reconnect", () => {
|
|
47
|
+
const store = new TranscriptStore(db);
|
|
48
|
+
store.ensureSchema();
|
|
49
|
+
|
|
50
|
+
const ring = new ReplayRing({ capacity: 1024 });
|
|
51
|
+
const emit = (chunk: string) => {
|
|
52
|
+
const entry = ring.append(chunk);
|
|
53
|
+
// Long-lived: persist incrementally as the live stream advances.
|
|
54
|
+
store.record("session-1", [{ offset: entry.offset, chunk: entry.chunk }], "long-lived");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
emit("line-0\n");
|
|
58
|
+
emit("line-1\n");
|
|
59
|
+
emit("line-2\n");
|
|
60
|
+
|
|
61
|
+
// A consumer that saw through offset 1 reconnects and reattaches from offset 2.
|
|
62
|
+
const resume = store.since("session-1", 2);
|
|
63
|
+
assert.equal(resume.gap, false);
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
resume.entries.map((e) => e.chunk),
|
|
66
|
+
["line-2\n"],
|
|
67
|
+
);
|
|
68
|
+
assert.equal(resume.nextOffset, 3);
|
|
69
|
+
|
|
70
|
+
// The stream keeps advancing after the reattach; a from-0 reattach still works.
|
|
71
|
+
emit("line-3\n");
|
|
72
|
+
const full = store.since("session-1", 0);
|
|
73
|
+
assert.equal(full.gap, false);
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
full.entries.map((e) => e.offset),
|
|
76
|
+
[0, 1, 2, 3],
|
|
77
|
+
);
|
|
78
|
+
assert.equal(full.nextOffset, 4);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("flush advances nextOffset to the ring's high-water mark even when early chunks were evicted", () => {
|
|
82
|
+
const store = new TranscriptStore(db);
|
|
83
|
+
store.ensureSchema();
|
|
84
|
+
|
|
85
|
+
// A small ring evicts its head: 5 produced, capacity 3 → only offsets 2,3,4 retained.
|
|
86
|
+
const ring = new ReplayRing({ capacity: 3 });
|
|
87
|
+
for (let i = 0; i < 5; i++) ring.append(`chunk-${i}`);
|
|
88
|
+
assert.equal(ring.nextOffset, 5);
|
|
89
|
+
assert.equal(ring.firstOffset, 2);
|
|
90
|
+
|
|
91
|
+
const flushed = store.flush("run-evict", ring, "ephemeral");
|
|
92
|
+
assert.equal(flushed, 3);
|
|
93
|
+
|
|
94
|
+
const meta = store.get("run-evict");
|
|
95
|
+
// The durable window is exactly what the ring still retained…
|
|
96
|
+
assert.equal(meta?.firstOffset, 2);
|
|
97
|
+
// …but nextOffset reflects everything ever produced, so reattach gap accounting
|
|
98
|
+
// downstream stays correct.
|
|
99
|
+
assert.equal(meta?.nextOffset, 5);
|
|
100
|
+
|
|
101
|
+
// A reattach from an evicted offset reports a gap (retention lost those chunks).
|
|
102
|
+
const slice = store.since("run-evict", 0);
|
|
103
|
+
assert.equal(slice.gap, true);
|
|
104
|
+
assert.deepEqual(
|
|
105
|
+
slice.entries.map((e) => e.offset),
|
|
106
|
+
[2, 3, 4],
|
|
107
|
+
);
|
|
108
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import {
|
|
6
|
+
TRANSCRIPT_CHUNK_TABLE,
|
|
7
|
+
TRANSCRIPT_SCHEMA_SQL,
|
|
8
|
+
TRANSCRIPT_STREAM_TABLE,
|
|
9
|
+
} from "./schema.ts";
|
|
10
|
+
import { TranscriptStore } from "./store.ts";
|
|
11
|
+
import { openTestDb } from "./test-db.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalise SQL for a drift comparison: drop `-- …` line comments, collapse all
|
|
15
|
+
* runs of whitespace to a single space, and trim. Two DDL scripts that create the
|
|
16
|
+
* same objects with the same columns normalise identically regardless of
|
|
17
|
+
* comments/indentation.
|
|
18
|
+
*/
|
|
19
|
+
function normaliseSql(sql: string): string {
|
|
20
|
+
return sql
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
23
|
+
.join("\n")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const migrationPath = fileURLToPath(
|
|
29
|
+
new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
test("the boot migration and TranscriptStore's DDL do not drift", () => {
|
|
33
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
34
|
+
assert.equal(
|
|
35
|
+
normaliseSql(migrationSql),
|
|
36
|
+
normaliseSql(TRANSCRIPT_SCHEMA_SQL),
|
|
37
|
+
"db/migrations/002_agentic_transcript.sql must match TRANSCRIPT_SCHEMA_SQL — update both together",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
42
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
43
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_stream/);
|
|
44
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_chunk/);
|
|
45
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
46
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the migration takes prefix 002, after S2's 001 and before S7", () => {
|
|
50
|
+
// Numbering is a shared epic surface (see migration header). Guard the prefix so
|
|
51
|
+
// a rebase that renumbers is caught here rather than at merge time.
|
|
52
|
+
const stream = new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url).pathname;
|
|
53
|
+
assert.match(stream, /\/002_agentic_transcript\.sql$/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("ensureSchema creates both transcript tables idempotently", () => {
|
|
57
|
+
const db = openTestDb();
|
|
58
|
+
const store = new TranscriptStore(db);
|
|
59
|
+
store.ensureSchema();
|
|
60
|
+
store.ensureSchema();
|
|
61
|
+
const tables = db
|
|
62
|
+
.all<{ name: string }>(
|
|
63
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?) ORDER BY name",
|
|
64
|
+
[TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE],
|
|
65
|
+
)
|
|
66
|
+
.map((r) => r.name);
|
|
67
|
+
assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE]);
|
|
68
|
+
db.close();
|
|
69
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
|
|
23
|
+
/** The per-stream metadata table name. */
|
|
24
|
+
export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
|
|
25
|
+
|
|
26
|
+
/** The durable per-chunk table name. */
|
|
27
|
+
export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canonical transcript-store DDL. Forward-only and additive; every column
|
|
31
|
+
* added here must also be added to the boot migration (the drift guard enforces
|
|
32
|
+
* it). Chunks are immutable once written — retention drops whole windows/streams,
|
|
33
|
+
* it never rewrites a chunk.
|
|
34
|
+
*/
|
|
35
|
+
export const TRANSCRIPT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_STREAM_TABLE} (
|
|
36
|
+
stream TEXT PRIMARY KEY,
|
|
37
|
+
lifecycle TEXT NOT NULL,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
completed_at TEXT,
|
|
41
|
+
first_offset INTEGER,
|
|
42
|
+
next_offset INTEGER NOT NULL DEFAULT 0
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
|
|
45
|
+
stream TEXT NOT NULL,
|
|
46
|
+
chunk_offset INTEGER NOT NULL,
|
|
47
|
+
chunk TEXT NOT NULL,
|
|
48
|
+
appended_at TEXT NOT NULL,
|
|
49
|
+
PRIMARY KEY (stream, chunk_offset)
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
|