@fitchmultz/pi-subagents 0.48.0 → 0.49.1

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.
@@ -0,0 +1,321 @@
1
+ import { lstatSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { DatabaseSync } from "node:sqlite";
4
+ import { privateDatabase, privateFile, stateDirectory } from "./bridge-storage.ts";
5
+ import { readBridgeFile } from "./bridge-files.ts";
6
+ import { isRecord, isUnknownArray } from "../shared/unknown.ts";
7
+ import { object } from "./bridge-protocol.ts";
8
+ import { WakeOwner, WAKE_OWNER_FILE, stoppedWakeOwner } from "./bridge-wake-owner.ts";
9
+
10
+ const HOUR_MS = 3600000;
11
+ const LIMIT = 20;
12
+ const RETRY_MS = 30000;
13
+ const MAX_BACKOFF_MS = 300000;
14
+ const FILE = "wake-quota.sqlite";
15
+ const ATTEMPTS = `CREATE TABLE wake_attempts(
16
+ sequence INTEGER PRIMARY KEY, attemptedAt INTEGER NOT NULL,
17
+ settledAt INTEGER, owner TEXT,
18
+ CHECK(settledAt IS NOT NULL OR owner IS NOT NULL))`;
19
+
20
+ function integer(value: unknown): number {
21
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
22
+ throw new Error("Invalid durable wake state.");
23
+ }
24
+ return value;
25
+ }
26
+
27
+ /** One helper ledger, independent of identity, certificate, URL and credential rotation. */
28
+ export class WakeState {
29
+ private readonly db: DatabaseSync;
30
+ private readonly path: string;
31
+ private readonly stateDir: string;
32
+ private readonly inode: number;
33
+ private readonly device: number;
34
+ private readonly owner: WakeOwner;
35
+ private finishing = false;
36
+
37
+ constructor(stateDir: string) {
38
+ this.stateDir = stateDir;
39
+ this.path = join(stateDir, FILE);
40
+ const { db, created } = privateDatabase(stateDir, FILE);
41
+ this.db = db;
42
+ const stat = lstatSync(this.path);
43
+ this.inode = stat.ino;
44
+ this.device = stat.dev;
45
+ try {
46
+ if (created) {
47
+ this.initialize();
48
+ }
49
+ this.verify();
50
+ this.migrate();
51
+ this.owner = this.createOwner();
52
+ } catch (error) {
53
+ db.close();
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ private verify(): void {
59
+ const db = this.db;
60
+ if (db.prepare("PRAGMA quick_check").get()?.quick_check !== "ok") {
61
+ throw new Error("Wake ledger is corrupt.");
62
+ }
63
+ const meta = db.prepare("SELECT version, clock FROM wake_meta WHERE singleton=1").get();
64
+ if (!meta || (meta.version !== 1 && meta.version !== 2)) {
65
+ throw new Error("Unsupported wake ledger.");
66
+ }
67
+ integer(meta.clock);
68
+ this.verifyAttempts(meta.version);
69
+ for (const row of db.prepare("SELECT due, backoff FROM wake_batches").all()) {
70
+ integer(row.due);
71
+ const backoff = integer(row.backoff);
72
+ if ((backoff !== 0 && backoff < RETRY_MS) || backoff > MAX_BACKOFF_MS) {
73
+ throw new Error("Invalid wake retry state.");
74
+ }
75
+ }
76
+ }
77
+
78
+ private createOwner(): WakeOwner {
79
+ let owner: WakeOwner | undefined;
80
+ try {
81
+ // Recovery holds this same ledger lock: it cannot reap a file before its owner locks it.
82
+ return this.transaction(() => {
83
+ owner = new WakeOwner(this.stateDir);
84
+ return owner;
85
+ });
86
+ } catch (error) {
87
+ owner?.close();
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ private verifyAttempts(version: number): void {
93
+ for (const row of this.db.prepare("SELECT attemptedAt FROM wake_attempts").all()) {
94
+ integer(row.attemptedAt);
95
+ }
96
+ if (version !== 2) {
97
+ return;
98
+ }
99
+ for (const row of this.db
100
+ .prepare("SELECT attemptedAt, settledAt, owner FROM wake_attempts")
101
+ .all()) {
102
+ if (row.settledAt !== null) {
103
+ if (integer(row.settledAt) < integer(row.attemptedAt)) {
104
+ throw new Error("Invalid wake completion time.");
105
+ }
106
+ } else if (typeof row.owner !== "string" || !WAKE_OWNER_FILE.test(row.owner)) {
107
+ throw new Error("Invalid open wake attempt.");
108
+ }
109
+ }
110
+ }
111
+
112
+ private initialize(): void {
113
+ // Timestamped legacy outcomes omit crash-before-log attempts. Verified preflight may provide
114
+ // complete history plus a bounded unknown reservation; absent proof reserves the full hour.
115
+ const legacy = readdirSync(this.stateDir).some(
116
+ (name) => name !== FILE && (name.endsWith(".sqlite") || name.startsWith("presence-")),
117
+ );
118
+ const now = Date.now();
119
+ const attempts = this.history(now) ?? (legacy ? Array<number>(LIMIT).fill(now) : []);
120
+ this.db.exec(`BEGIN IMMEDIATE;
121
+ CREATE TABLE wake_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), version INTEGER NOT NULL, clock INTEGER NOT NULL);
122
+ ${ATTEMPTS};
123
+ CREATE TABLE wake_batches(bucket TEXT PRIMARY KEY, due INTEGER NOT NULL, backoff INTEGER NOT NULL);`);
124
+ this.db.prepare("INSERT INTO wake_meta VALUES(1,2,?)").run(now);
125
+ const insert = this.db.prepare("INSERT INTO wake_attempts(attemptedAt,settledAt) VALUES(?,?)");
126
+ for (const timestamp of attempts) {
127
+ insert.run(timestamp, timestamp);
128
+ }
129
+ this.db.exec("COMMIT");
130
+ }
131
+
132
+ private migrate(): void {
133
+ this.transaction(() => {
134
+ if (this.db.prepare("SELECT version FROM wake_meta").get()?.version !== 1) {
135
+ return;
136
+ }
137
+ // V1 timestamps precede COMMIT/connect/TLS, so none prove when a POST finished.
138
+ // Stop all old writers before upgrading; the new CHECK also denies their old INSERT shape.
139
+ const now = Math.max(
140
+ Date.now(),
141
+ integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock),
142
+ );
143
+ this.db.exec(`ALTER TABLE wake_attempts RENAME TO wake_attempts_v1; ${ATTEMPTS};`);
144
+ this.db
145
+ .prepare(`INSERT INTO wake_attempts(sequence,attemptedAt,settledAt)
146
+ SELECT sequence,attemptedAt,MAX(attemptedAt,?) FROM wake_attempts_v1`)
147
+ .run(now);
148
+ this.db.exec("DROP TABLE wake_attempts_v1");
149
+ this.db.prepare("UPDATE wake_meta SET version=2,clock=?").run(now);
150
+ });
151
+ }
152
+
153
+ private history(now: number): number[] | undefined {
154
+ const path = join(this.stateDir, "wake-history.json");
155
+ let input: unknown;
156
+ try {
157
+ privateFile(path);
158
+ input = JSON.parse(readBridgeFile(path, true, 1024 * 1024).toString("utf8"));
159
+ } catch (error) {
160
+ if (isRecord(error) && error.code === "ENOENT") {
161
+ return;
162
+ }
163
+ throw error;
164
+ }
165
+ const raw = object(input, ["coveredSince", "stoppedAt", "attempts", "unknownAttempts"]);
166
+ const coveredSince = integer(raw.coveredSince);
167
+ const stoppedAt = integer(raw.stoppedAt);
168
+ const unknownAttempts = integer(raw.unknownAttempts);
169
+ if (
170
+ coveredSince > stoppedAt - HOUR_MS ||
171
+ stoppedAt > now ||
172
+ unknownAttempts > LIMIT ||
173
+ !isUnknownArray(raw.attempts) ||
174
+ raw.attempts.length > 10000
175
+ ) {
176
+ throw new Error("Invalid legacy wake history; never reset prior usage.");
177
+ }
178
+ const attempts = raw.attempts.map(integer);
179
+ if (attempts.some((timestamp) => timestamp < coveredSince || timestamp > stoppedAt)) {
180
+ throw new Error("Attempt outside attested history coverage.");
181
+ }
182
+ return attempts
183
+ .filter((timestamp) => timestamp > now - HOUR_MS)
184
+ .concat(Array<number>(unknownAttempts).fill(now));
185
+ }
186
+
187
+ private validate(): void {
188
+ stateDirectory(this.stateDir);
189
+ privateFile(this.path);
190
+ const stat = lstatSync(this.path);
191
+ if (stat.ino !== this.inode || stat.dev !== this.device) {
192
+ throw new Error("Wake ledger was replaced.");
193
+ }
194
+ }
195
+
196
+ deadline(bucket: string): number {
197
+ this.validate();
198
+ const existing = this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket);
199
+ if (existing) {
200
+ return integer(existing.due);
201
+ }
202
+ // First observation occurs only after mailbox FULL commit, never at the pre-write receivedAt.
203
+ // Keep this grace durable too: new arrivals/reload/restart cannot restart an existing deadline.
204
+ this.db
205
+ .prepare("INSERT OR IGNORE INTO wake_batches VALUES(?,?,0)")
206
+ .run(bucket, Date.now() + 2000);
207
+ return integer(this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket)?.due);
208
+ }
209
+
210
+ retire(bucket: string): void {
211
+ this.validate();
212
+ this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
213
+ }
214
+
215
+ /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
216
+ reserve(bucket: string): number | undefined {
217
+ this.retrySettlement();
218
+ return this.transaction(() => this.reservation(bucket));
219
+ }
220
+
221
+ retrySettlement(): void {
222
+ if (this.finishing) {
223
+ this.settle();
224
+ }
225
+ }
226
+
227
+ private transaction<T>(action: () => T): T {
228
+ this.validate();
229
+ this.db.exec("BEGIN IMMEDIATE");
230
+ try {
231
+ const result = action();
232
+ this.db.exec("COMMIT");
233
+ return result;
234
+ } catch (error) {
235
+ this.db.exec("ROLLBACK");
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ /** A failed settlement stays open and must be retried before this owner can dispatch again. */
241
+ settle(): void {
242
+ this.finishing = true;
243
+ this.transaction(() => this.finishOwner(this.owner.name));
244
+ this.finishing = false;
245
+ }
246
+
247
+ private finishOwner(owner: string): void {
248
+ const now = Math.max(
249
+ Date.now(),
250
+ integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock),
251
+ );
252
+ this.db
253
+ .prepare(`UPDATE wake_attempts SET settledAt=MAX(attemptedAt,?)
254
+ WHERE owner=? AND settledAt IS NULL`)
255
+ .run(now, owner);
256
+ this.db.prepare("UPDATE wake_meta SET clock=?").run(now);
257
+ }
258
+
259
+ private recover(): void {
260
+ const names = new Set(readdirSync(this.stateDir).filter((name) => WAKE_OWNER_FILE.test(name)));
261
+ for (const row of this.db
262
+ .prepare("SELECT DISTINCT owner FROM wake_attempts WHERE settledAt IS NULL")
263
+ .all()) {
264
+ if (typeof row.owner !== "string") {
265
+ throw new Error("Invalid open wake owner.");
266
+ }
267
+ names.add(row.owner);
268
+ }
269
+ for (const name of names) {
270
+ if (name !== this.owner.name && stoppedWakeOwner(this.stateDir, name)) {
271
+ // The owner cannot send again. Unknown outcomes retain a full hour from this observation.
272
+ this.finishOwner(name);
273
+ rmSync(join(this.stateDir, name), { force: true });
274
+ }
275
+ }
276
+ }
277
+
278
+ private reservation(bucket: string): number | undefined {
279
+ this.recover();
280
+ const clock = integer(
281
+ this.db.prepare("SELECT clock FROM wake_meta WHERE singleton=1").get()?.clock,
282
+ );
283
+ const now = Date.now();
284
+ if (now < clock) {
285
+ return clock;
286
+ }
287
+ const row = this.db.prepare("SELECT due, backoff FROM wake_batches WHERE bucket=?").get(bucket);
288
+ if (row && integer(row.due) > now) {
289
+ return integer(row.due);
290
+ }
291
+ this.db.prepare("DELETE FROM wake_attempts WHERE settledAt<=?").run(now - HOUR_MS);
292
+ const attempts = this.db
293
+ .prepare("SELECT settledAt FROM wake_attempts ORDER BY settledAt")
294
+ .all();
295
+ if (attempts.length >= LIMIT) {
296
+ const oldest = attempts.find((attempt) => attempt.settledAt !== null);
297
+ // Only open/unknown attempts have no expiry yet; revisit on the existing recovery backoff.
298
+ return oldest ? integer(oldest.settledAt) + HOUR_MS : now + RETRY_MS;
299
+ }
300
+ const backoff = row
301
+ ? Math.max(RETRY_MS, Math.min(integer(row.backoff) * 2, MAX_BACKOFF_MS))
302
+ : RETRY_MS;
303
+ this.db
304
+ .prepare("INSERT INTO wake_attempts(attemptedAt,owner) VALUES(?,?)")
305
+ .run(now, this.owner.name);
306
+ this.db.prepare("UPDATE wake_meta SET clock=? WHERE singleton=1").run(now);
307
+ this.db
308
+ .prepare("INSERT OR REPLACE INTO wake_batches VALUES(?,?,?)")
309
+ .run(bucket, now + backoff, backoff);
310
+ return;
311
+ }
312
+
313
+ close(): void {
314
+ try {
315
+ this.settle();
316
+ } finally {
317
+ this.db.close();
318
+ this.owner.close();
319
+ }
320
+ }
321
+ }