@maolon/pi-watcher 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +255 -2
  4. package/dist/cli.d.ts +8 -0
  5. package/dist/cli.js +369 -0
  6. package/dist/contracts/interfaces.d.ts +262 -0
  7. package/dist/contracts/interfaces.js +1 -0
  8. package/dist/contracts/policy-defaults.json +61 -0
  9. package/dist/contracts/relay-next.interfaces.d.ts +160 -0
  10. package/dist/contracts/relay-next.interfaces.js +1 -0
  11. package/dist/engine/cards.d.ts +68 -0
  12. package/dist/engine/cards.js +76 -0
  13. package/dist/engine/engine.d.ts +178 -0
  14. package/dist/engine/engine.js +1162 -0
  15. package/dist/engine/hard-rules.d.ts +53 -0
  16. package/dist/engine/hard-rules.js +96 -0
  17. package/dist/engine/semantic.d.ts +49 -0
  18. package/dist/engine/semantic.js +125 -0
  19. package/dist/engine/service.d.ts +62 -0
  20. package/dist/engine/service.js +587 -0
  21. package/dist/engine/tool-actions.d.ts +35 -0
  22. package/dist/engine/tool-actions.js +348 -0
  23. package/dist/engine/widget.d.ts +29 -0
  24. package/dist/engine/widget.js +52 -0
  25. package/dist/ipc/client.d.ts +26 -0
  26. package/dist/ipc/client.js +106 -0
  27. package/dist/ipc/server.d.ts +78 -0
  28. package/dist/ipc/server.js +105 -0
  29. package/dist/jev/client.d.ts +38 -0
  30. package/dist/jev/client.js +239 -0
  31. package/dist/jev/consent.d.ts +12 -0
  32. package/dist/jev/consent.js +37 -0
  33. package/dist/jev/index.d.ts +9 -0
  34. package/dist/jev/index.js +9 -0
  35. package/dist/jev/mock.d.ts +30 -0
  36. package/dist/jev/mock.js +77 -0
  37. package/dist/jev/pi-registry.d.ts +66 -0
  38. package/dist/jev/pi-registry.js +127 -0
  39. package/dist/jev/questions.d.ts +15 -0
  40. package/dist/jev/questions.js +76 -0
  41. package/dist/jev/sanitizer.d.ts +10 -0
  42. package/dist/jev/sanitizer.js +59 -0
  43. package/dist/jev/types.d.ts +78 -0
  44. package/dist/jev/types.js +54 -0
  45. package/dist/pi-extension.d.ts +123 -0
  46. package/dist/pi-extension.js +687 -0
  47. package/dist/relay/managed.d.ts +120 -0
  48. package/dist/relay/managed.js +482 -0
  49. package/dist/relay/negotiate.d.ts +39 -0
  50. package/dist/relay/negotiate.js +112 -0
  51. package/dist/runtime.d.ts +74 -0
  52. package/dist/runtime.js +246 -0
  53. package/dist/source/agent-check/adapter.d.ts +57 -0
  54. package/dist/source/agent-check/adapter.js +224 -0
  55. package/dist/source/agent-file/adapter.d.ts +57 -0
  56. package/dist/source/agent-file/adapter.js +217 -0
  57. package/dist/source/task-status-v1/adapter.d.ts +36 -0
  58. package/dist/source/task-status-v1/adapter.js +263 -0
  59. package/dist/source/task-status-v1/producer.d.ts +81 -0
  60. package/dist/source/task-status-v1/producer.js +127 -0
  61. package/dist/storage/lock.d.ts +14 -0
  62. package/dist/storage/lock.js +66 -0
  63. package/dist/storage/schema.sql +166 -0
  64. package/dist/storage/store.d.ts +352 -0
  65. package/dist/storage/store.js +555 -0
  66. package/dist/util/clock.d.ts +23 -0
  67. package/dist/util/clock.js +34 -0
  68. package/dist/util/ids.d.ts +8 -0
  69. package/dist/util/ids.js +29 -0
  70. package/dist/util/result.d.ts +28 -0
  71. package/dist/util/result.js +54 -0
  72. package/package.json +101 -4
@@ -0,0 +1,555 @@
1
+ /**
2
+ * WatchStore: local truth store (design 07).
3
+ * - SQLite WAL / synchronous=FULL / foreign_keys=ON
4
+ * - Each process holds the root flock (fixed inode); runtimeEpoch increments on every open
5
+ * - transaction(): synchronous, bounded transaction body (external I/O is forbidden inside)
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import Database from 'better-sqlite3';
11
+ import { RootLock } from './lock.js';
12
+ import { WatcherError } from '../util/result.js';
13
+ const SCHEMA_SQL_PATH = fileURLToPath(new URL('./schema.sql', import.meta.url));
14
+ export function emptySnapshot() {
15
+ return {
16
+ taskState: 'unknown',
17
+ stage: null,
18
+ exitCode: null,
19
+ summary: null,
20
+ checks: [],
21
+ artifactIds: [],
22
+ coverage: { truncated: false, sourceGap: false },
23
+ scopeRevision: 0,
24
+ backoffMs: 0
25
+ };
26
+ }
27
+ export class WatchStore {
28
+ rootDir;
29
+ dbPath;
30
+ evidenceDir;
31
+ resultsDir;
32
+ runtimeEpoch;
33
+ db;
34
+ lock;
35
+ closed = false;
36
+ constructor(rootDir, db, lock, epoch) {
37
+ this.rootDir = rootDir;
38
+ this.dbPath = path.join(rootDir, 'watcher.db');
39
+ this.evidenceDir = path.join(rootDir, 'evidence');
40
+ this.resultsDir = path.join(rootDir, 'results');
41
+ this.db = db;
42
+ this.lock = lock;
43
+ this.runtimeEpoch = epoch;
44
+ }
45
+ static async open(rootDir, options = {}) {
46
+ fs.mkdirSync(rootDir, { recursive: true });
47
+ const lock = new RootLock(rootDir);
48
+ if (!options.noLock) {
49
+ await lock.requireAcquire();
50
+ }
51
+ const dbPath = path.join(rootDir, 'watcher.db');
52
+ const db = new Database(dbPath);
53
+ db.pragma('journal_mode = WAL');
54
+ db.pragma('synchronous = FULL');
55
+ db.pragma('foreign_keys = ON');
56
+ const hasMeta = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='runtime_meta'").get() !== undefined;
57
+ if (!hasMeta) {
58
+ const sql = fs.readFileSync(SCHEMA_SQL_PATH, 'utf8');
59
+ db.exec(sql);
60
+ }
61
+ // runtime_meta singleton row: created on first open (CHECK requires epoch>0, initialized to 1), then incremented on every process open (design 7.2 fencing)
62
+ db.prepare(`INSERT OR IGNORE INTO runtime_meta (singleton,schema_version,runtime_epoch,mode,recovery_attention_hold)
63
+ VALUES (1,1,1,?,1)`).run(options.mode ?? 'embedded');
64
+ // Bump runtime epoch for this process; every commit is fenced by it.
65
+ const bump = db.transaction(() => {
66
+ db.prepare("UPDATE runtime_meta SET runtime_epoch = runtime_epoch + 1, mode = ? WHERE singleton = 1")
67
+ .run(options.mode ?? 'embedded');
68
+ const row = db.prepare('SELECT runtime_epoch FROM runtime_meta WHERE singleton = 1').get();
69
+ return row.runtime_epoch;
70
+ });
71
+ const epoch = bump();
72
+ return new WatchStore(rootDir, db, options.noLock ? null : lock, epoch);
73
+ }
74
+ transaction(body) {
75
+ if (this.closed)
76
+ throw new WatcherError('STORE_UNAVAILABLE', 'store is closed');
77
+ const fence = this.db.prepare('SELECT runtime_epoch FROM runtime_meta WHERE singleton = 1').get();
78
+ if (fence.runtime_epoch !== this.runtimeEpoch) {
79
+ throw new WatcherError('STORE_UNAVAILABLE', 'stale runtime epoch: this store handle is fenced out');
80
+ }
81
+ this.db.exec('BEGIN IMMEDIATE');
82
+ let tx;
83
+ try {
84
+ tx = this.makeTx();
85
+ const out = body(tx);
86
+ this.db.exec('COMMIT');
87
+ return out;
88
+ }
89
+ catch (e) {
90
+ try {
91
+ this.db.exec('ROLLBACK');
92
+ }
93
+ catch {
94
+ /* already rolled back */
95
+ }
96
+ throw e;
97
+ }
98
+ }
99
+ get isClosed() {
100
+ return this.closed;
101
+ }
102
+ /** Connection-level pragma diagnostics (WAL persists; synchronous/foreign_keys are connection-level). */
103
+ pragmaReport() {
104
+ return {
105
+ journalMode: this.db.pragma('journal_mode', { simple: true }),
106
+ synchronous: this.db.pragma('synchronous', { simple: true }),
107
+ foreignKeys: this.db.pragma('foreign_keys', { simple: true })
108
+ };
109
+ }
110
+ close() {
111
+ if (this.closed)
112
+ return;
113
+ this.closed = true;
114
+ this.db.close();
115
+ if (this.lock) {
116
+ void this.lock.release();
117
+ }
118
+ }
119
+ // --- evidence blocks: content-addressed files under root/evidence ---
120
+ writeEvidenceBlock(content) {
121
+ const { createHash } = require('node:crypto');
122
+ const sha = createHash('sha256').update(content, 'utf8').digest('hex');
123
+ fs.mkdirSync(this.evidenceDir, { recursive: true });
124
+ const locator = path.join('evidence', `${sha}.txt`);
125
+ const abs = path.join(this.rootDir, locator);
126
+ if (!fs.existsSync(abs)) {
127
+ fs.writeFileSync(abs, content, { encoding: 'utf8' });
128
+ }
129
+ return { sha256: sha, relativeLocator: locator, bytes: Buffer.byteLength(content, 'utf8') };
130
+ }
131
+ writeResultCard(watchId, resultId, card) {
132
+ const dir = path.join(this.resultsDir, watchId);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const abs = path.join(dir, `${resultId}.json`);
135
+ fs.writeFileSync(abs, JSON.stringify(card, null, 2), 'encoding' in { encoding: 'utf8' } ? 'utf8' : undefined);
136
+ return path.relative(this.rootDir, abs);
137
+ }
138
+ listResultCards(watchId) {
139
+ const dir = path.join(this.resultsDir, watchId);
140
+ if (!fs.existsSync(dir))
141
+ return [];
142
+ return fs.readdirSync(dir)
143
+ .filter(f => f.endsWith('.json'))
144
+ .sort()
145
+ .map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
146
+ }
147
+ // --- transaction body ---
148
+ makeTx() {
149
+ const db = this.db;
150
+ const selWatch = db.prepare('SELECT * FROM watches WHERE watch_id = ?');
151
+ const insWatch = db.prepare(`INSERT INTO watches
152
+ (watch_id,generation,mission_revision,control_revision,lifecycle,health,owner_session,owner_binding_epoch,
153
+ spec_json,snapshot_json,observation_seq,next_due_at,updated_at)
154
+ VALUES (?,?,?,?,?,?,?,?,?,?,0,?,?)`);
155
+ const updWatch = db.prepare(`UPDATE watches SET
156
+ lifecycle=COALESCE(?,lifecycle), health=COALESCE(?,health), control_revision=COALESCE(?,control_revision),
157
+ mission_revision=COALESCE(?,mission_revision), snapshot_json=COALESCE(?,snapshot_json),
158
+ observation_seq=COALESCE(?,observation_seq), next_due_at=COALESCE(?,next_due_at), updated_at=?
159
+ WHERE watch_id=?`);
160
+ const insObs = db.prepare(`INSERT OR IGNORE INTO observations
161
+ (observation_id,watch_id,generation,source_id,attempt_id,source_seq,local_seq,observed_at,digest,payload_json)
162
+ VALUES (?,?,?,?,?,?,?,?,?,?)`);
163
+ const tx = {
164
+ meta: () => {
165
+ const r = db.prepare('SELECT schema_version,runtime_epoch,mode,recovery_attention_hold FROM runtime_meta WHERE singleton=1')
166
+ .get();
167
+ return {
168
+ schemaVersion: r.schema_version,
169
+ runtimeEpoch: r.runtime_epoch,
170
+ mode: r.mode,
171
+ recoveryAttentionHold: r.recovery_attention_hold === 1
172
+ };
173
+ },
174
+ clearRecoveryHold: () => {
175
+ db.prepare('UPDATE runtime_meta SET recovery_attention_hold = 0 WHERE singleton = 1').run();
176
+ },
177
+ getWatchRow: (watchId) => {
178
+ const r = selWatch.get(watchId);
179
+ return r ? rowToWatchRow(r) : undefined;
180
+ },
181
+ getWatch: (watchId) => tx.getWatchRow(watchId)?.spec,
182
+ compareRevision: (basis) => {
183
+ const r = selWatch.get(basis.watchId);
184
+ if (!r)
185
+ return false;
186
+ return (r.generation === basis.generation &&
187
+ r.mission_revision === basis.missionRevision &&
188
+ r.control_revision === basis.controlRevision &&
189
+ basis.observationSeq <= r.observation_seq);
190
+ },
191
+ persistCommandResult: (actorId, requestId, digest, result) => {
192
+ const existing = db.prepare('SELECT digest,response_json FROM commands WHERE actor_id=? AND request_id=?')
193
+ .get(actorId, requestId);
194
+ if (existing) {
195
+ if (existing.digest === digest) {
196
+ return { duplicate: true, previous: JSON.parse(existing.response_json) };
197
+ }
198
+ throw new WatcherError('REQUEST_CONFLICT', `requestId ${requestId} replayed with different digest`);
199
+ }
200
+ db.prepare('INSERT INTO commands (request_id,actor_id,digest,response_json,committed_at) VALUES (?,?,?,?,?)')
201
+ .run(requestId, actorId, digest, JSON.stringify(result), Date.now());
202
+ return { duplicate: false };
203
+ },
204
+ getCommand: (actorId, requestId) => {
205
+ const r = db.prepare('SELECT digest,response_json FROM commands WHERE actor_id=? AND request_id=?')
206
+ .get(actorId, requestId);
207
+ return r ? { digest: r.digest, response: JSON.parse(r.response_json) } : undefined;
208
+ },
209
+ insertWatch: (row) => {
210
+ insWatch.run(row.watchId, row.generation, row.missionRevision, row.controlRevision, row.lifecycle, row.health, row.ownerSession, row.ownerBindingEpoch, JSON.stringify(row.spec), JSON.stringify(row.snapshot), row.nextDueAt, row.now);
211
+ },
212
+ updateWatch: (watchId, patch, now) => {
213
+ const cur = selWatch.get(watchId);
214
+ if (!cur)
215
+ throw new WatcherError('UNKNOWN_TARGET', `watch ${watchId} not found`);
216
+ updWatch.run(patch.lifecycle ?? null, patch.health ?? null, patch.controlRevision ?? null, patch.missionRevision ?? null, patch.snapshot ? JSON.stringify(patch.snapshot) : null, patch.observationSeq ?? null, patch.nextDueAt === undefined ? null : patch.nextDueAt, now, watchId);
217
+ },
218
+ bumpObservationSeq: (watchId) => {
219
+ db.prepare('UPDATE watches SET observation_seq = observation_seq + 1 WHERE watch_id = ?').run(watchId);
220
+ const r = selWatch.get(watchId);
221
+ return r.observation_seq;
222
+ },
223
+ insertObservation: (input) => {
224
+ const info = insObs.run(input.observationId, input.watchId, input.generation, input.sourceId, input.attemptId, input.sourceSeq, input.localSeq, input.observedAt, input.digest, JSON.stringify(input.payload));
225
+ return info.changes === 1;
226
+ },
227
+ getCursor: (watchId, sourceId, generation) => {
228
+ const r = db.prepare('SELECT cursor_json FROM cursors WHERE watch_id=? AND source_id=? AND generation=?')
229
+ .get(watchId, sourceId, generation);
230
+ return r ? JSON.parse(r.cursor_json) : undefined;
231
+ },
232
+ putCursor: (watchId, sourceId, generation, cursor) => {
233
+ db.prepare(`INSERT INTO cursors (watch_id,source_id,generation,cursor_json) VALUES (?,?,?,?)
234
+ ON CONFLICT(watch_id,source_id,generation) DO UPDATE SET cursor_json=excluded.cursor_json`)
235
+ .run(watchId, sourceId, generation, JSON.stringify(cursor));
236
+ },
237
+ nextEpisodeOrdinal: (watchId, generation, kind, checkpointId) => {
238
+ const r = db.prepare('SELECT COALESCE(MAX(ordinal),0)+1 AS n FROM episodes WHERE watch_id=? AND generation=? AND kind=? AND checkpoint_id=?').get(watchId, generation, kind, checkpointId);
239
+ return r.n;
240
+ },
241
+ insertEpisode: (input) => {
242
+ db.prepare(`INSERT INTO episodes
243
+ (episode_id,watch_id,generation,kind,checkpoint_id,ordinal,revision,state,body_json,snooze_until,updated_at)
244
+ VALUES (?,?,?,?,?,?,1,'open',?,NULL,?)`)
245
+ .run(input.episodeId, input.watchId, input.generation, input.kind, input.checkpointId, input.ordinal, JSON.stringify(input.body), input.now);
246
+ },
247
+ getEpisode: (episodeId) => {
248
+ const r = db.prepare('SELECT * FROM episodes WHERE episode_id=?').get(episodeId);
249
+ return r ? rowToEpisodeRow(r) : undefined;
250
+ },
251
+ getActiveEpisodeBySlot: (watchId, generation, kind, checkpointId) => {
252
+ const r = db.prepare(`SELECT * FROM episodes
253
+ WHERE watch_id=? AND generation=? AND kind=? AND checkpoint_id=? AND state IN ('open','snoozed','acknowledged')
254
+ ORDER BY ordinal DESC LIMIT 1`)
255
+ .get(watchId, generation, kind, checkpointId);
256
+ return r ? rowToEpisodeRow(r) : undefined;
257
+ },
258
+ updateEpisode: (episodeId, expectedRevision, patch, now) => {
259
+ const info = db.prepare(`UPDATE episodes SET
260
+ state=COALESCE(?,state), body_json=COALESCE(?,body_json), snooze_until=COALESCE(?,snooze_until),
261
+ revision=revision+1, updated_at=?
262
+ WHERE episode_id=? AND revision=?`)
263
+ .run(patch.state ?? null, patch.body ? JSON.stringify(patch.body) : null, patch.snoozeUntil === undefined ? null : patch.snoozeUntil, now, episodeId, expectedRevision);
264
+ if (info.changes !== 1)
265
+ return null;
266
+ const r = db.prepare('SELECT revision FROM episodes WHERE episode_id=?').get(episodeId);
267
+ return r.revision;
268
+ },
269
+ listEpisodes: (watchId, limit) => {
270
+ const rows = db.prepare('SELECT * FROM episodes WHERE watch_id=? ORDER BY updated_at DESC LIMIT ?')
271
+ .all(watchId, limit);
272
+ return rows.map(rowToEpisodeRow);
273
+ },
274
+ insertOutbox: (input) => {
275
+ db.prepare(`INSERT INTO outbox
276
+ (event_id,watch_id,episode_id,generation,event_type,event_bytes,event_digest,valid_until,admission,admission_json,created_at,updated_at)
277
+ VALUES (?,?,?,?,?,?,?,?, ?, ?, ?, ?)`)
278
+ .run(input.eventId, input.watchId, input.episodeId, input.generation, input.eventType, input.eventBytes, input.eventDigest, input.validUntil, input.admission ?? 'pending', JSON.stringify(input.admissionJson ?? {}), input.now, input.now);
279
+ },
280
+ setOutboxAdmission: (eventId, admission, admissionJson, now) => {
281
+ db.prepare('UPDATE outbox SET admission=?, admission_json=?, updated_at=? WHERE event_id=?')
282
+ .run(admission, JSON.stringify(admissionJson), now, eventId);
283
+ },
284
+ getOutbox: (eventId) => {
285
+ const r = db.prepare('SELECT * FROM outbox WHERE event_id=?').get(eventId);
286
+ return r ? rowToOutboxRow(r) : undefined;
287
+ },
288
+ listOutboxByWatch: (watchId, limit) => {
289
+ const rows = db.prepare('SELECT * FROM outbox WHERE watch_id=? ORDER BY created_at DESC LIMIT ?')
290
+ .all(watchId, limit);
291
+ return rows.map(rowToOutboxRow);
292
+ },
293
+ insertControlOutbox: (input) => {
294
+ db.prepare(`INSERT INTO control_outbox
295
+ (operation_id,watch_id,scope_id,expected_revision,next_revision,requested_state,status,result_json,created_at)
296
+ VALUES (?,?,?,?,?,?, 'pending', '{}', ?)`)
297
+ .run(input.operationId, input.watchId, input.scopeId, input.expectedRevision, input.nextRevision, input.requestedState, input.now);
298
+ },
299
+ completeControlOutbox: (operationId, status, result) => {
300
+ if (result) {
301
+ db.prepare('UPDATE control_outbox SET status=?, result_json=? WHERE operation_id=?')
302
+ .run(status, JSON.stringify(result), operationId);
303
+ }
304
+ else {
305
+ db.prepare('UPDATE control_outbox SET status=? WHERE operation_id=?')
306
+ .run(status, operationId);
307
+ }
308
+ },
309
+ getControlOutboxStatus: (operationId) => {
310
+ const r = db.prepare('SELECT status FROM control_outbox WHERE operation_id=?').get(operationId);
311
+ return r?.status ?? null;
312
+ },
313
+ listOpenControlOutbox: (watchId) => {
314
+ const rows = (watchId
315
+ ? db.prepare(`SELECT * FROM control_outbox WHERE watch_id=? AND status IN ('pending','sending','unknown')
316
+ ORDER BY created_at, rowid`).all(watchId)
317
+ : db.prepare(`SELECT * FROM control_outbox WHERE status IN ('pending','sending','unknown')
318
+ ORDER BY created_at, rowid`).all());
319
+ return rows.map(r => ({
320
+ operationId: r.operation_id,
321
+ watchId: r.watch_id,
322
+ scopeId: r.scope_id,
323
+ expectedRevision: r.expected_revision,
324
+ nextRevision: r.next_revision,
325
+ requestedState: r.requested_state,
326
+ status: r.status,
327
+ createdAt: r.created_at
328
+ }));
329
+ },
330
+ mergeOutboxAdmissionJson: (eventId, patch, now) => {
331
+ const r = db.prepare('SELECT admission_json FROM outbox WHERE event_id=?').get(eventId);
332
+ if (!r)
333
+ return;
334
+ const merged = { ...JSON.parse(r.admission_json), ...patch };
335
+ db.prepare('UPDATE outbox SET admission_json=?, updated_at=? WHERE event_id=?')
336
+ .run(JSON.stringify(merged), now, eventId);
337
+ },
338
+ listOutboxAwaitingWithdraw: (limit) => {
339
+ const rows = db.prepare(`SELECT * FROM outbox WHERE event_type='watcher.attention.v1'
340
+ AND json_extract(admission_json,'$.withdraw.operationId') IS NOT NULL
341
+ AND json_extract(admission_json,'$.withdraw.result') IS NULL
342
+ ORDER BY created_at LIMIT ?`).all(limit);
343
+ return rows.map(rowToOutboxRow);
344
+ },
345
+ insertAppliedResponse: (input) => {
346
+ db.prepare(`INSERT INTO applied_responses
347
+ (response_id,watch_id,episode_id,delivery_ref,digest,result_json,applied_at)
348
+ VALUES (?,?,?,?,?,?,?)`)
349
+ .run(input.responseId, input.watchId, input.episodeId, input.deliveryRef, input.digest, JSON.stringify(input.result), input.now);
350
+ },
351
+ getAppliedResponse: (responseId) => {
352
+ const r = db.prepare('SELECT response_id,result_json FROM applied_responses WHERE response_id=?')
353
+ .get(responseId);
354
+ return r ? { responseId: r.response_id, result: JSON.parse(r.result_json) } : undefined;
355
+ },
356
+ insertAppliedConfirmOutbox: (operationId, responseId) => {
357
+ db.prepare(`INSERT INTO applied_confirmation_outbox (operation_id,response_id,status,result_json)
358
+ VALUES (?,?,'pending','{}')`).run(operationId, responseId);
359
+ },
360
+ listObservations: (watchId, limit) => {
361
+ const rows = db.prepare('SELECT observation_id,source_seq,local_seq,observed_at,digest,payload_json FROM observations WHERE watch_id=? ORDER BY local_seq DESC LIMIT ?')
362
+ .all(watchId, limit);
363
+ return rows.map(r => ({
364
+ observationId: r.observation_id,
365
+ sourceSeq: r.source_seq,
366
+ localSeq: r.local_seq,
367
+ observedAt: r.observed_at,
368
+ digest: r.digest,
369
+ payload: JSON.parse(r.payload_json)
370
+ }));
371
+ },
372
+ listWatches: (ownerSession, cursor, limit, lifecycles) => {
373
+ const lc = lifecycles && lifecycles.length > 0 ? ` AND lifecycle IN (${lifecycles.map(() => '?').join(',')})` : '';
374
+ const rows = (cursor
375
+ ? db.prepare(`SELECT * FROM watches WHERE owner_session=? AND watch_id > ?${lc} ORDER BY watch_id LIMIT ?`)
376
+ .all(ownerSession, cursor, ...lifecycles ?? [], limit)
377
+ : db.prepare(`SELECT * FROM watches WHERE owner_session=?${lc} ORDER BY watch_id LIMIT ?`)
378
+ .all(ownerSession, ...lifecycles ?? [], limit));
379
+ return rows.map(rowToWatchRow);
380
+ },
381
+ countWatches: (ownerSession, lifecycles) => {
382
+ const lc = lifecycles && lifecycles.length > 0 ? ` AND lifecycle IN (${lifecycles.map(() => '?').join(',')})` : '';
383
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM watches WHERE owner_session=?${lc}`)
384
+ .get(ownerSession, ...lifecycles ?? []);
385
+ return r.n;
386
+ },
387
+ listAllWatches: (limit) => {
388
+ const rows = db.prepare('SELECT * FROM watches ORDER BY watch_id LIMIT ?')
389
+ .all(limit);
390
+ return rows.map(rowToWatchRow);
391
+ },
392
+ countOpenEpisodes: (watchId) => {
393
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM episodes WHERE watch_id=? AND state IN ('open','snoozed','acknowledged')`)
394
+ .get(watchId);
395
+ return r.n;
396
+ },
397
+ countUnresolvedEpisodes: (ownerSession) => {
398
+ if (ownerSession) {
399
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM episodes e
400
+ JOIN watches w ON w.watch_id = e.watch_id
401
+ WHERE e.state IN ('open','snoozed','acknowledged') AND w.owner_session=?`)
402
+ .get(ownerSession);
403
+ return r.n;
404
+ }
405
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM episodes WHERE state IN ('open','snoozed','acknowledged')`)
406
+ .get();
407
+ return r.n;
408
+ },
409
+ countPendingAttentions: (now, ownerSession) => {
410
+ if (ownerSession) {
411
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM outbox o
412
+ JOIN episodes e ON e.episode_id = o.episode_id
413
+ JOIN watches w ON w.watch_id = e.watch_id
414
+ WHERE o.event_type='watcher.attention.v1' AND e.state IN ('open','snoozed')
415
+ AND o.admission != 'expired' AND o.valid_until > ? AND w.owner_session=?`)
416
+ .get(now, ownerSession);
417
+ return r.n;
418
+ }
419
+ const r = db.prepare(`SELECT COUNT(*) AS n FROM outbox o JOIN episodes e ON e.episode_id = o.episode_id
420
+ WHERE o.event_type='watcher.attention.v1' AND e.state IN ('open','snoozed')
421
+ AND o.admission != 'expired' AND o.valid_until > ?`)
422
+ .get(now);
423
+ return r.n;
424
+ },
425
+ supersedeEpisodesOfWatch: (watchId, now) => {
426
+ const info = db.prepare(`UPDATE episodes SET state='superseded', revision=revision+1, updated_at=?
427
+ WHERE watch_id=? AND state IN ('open','snoozed','acknowledged')`).run(now, watchId);
428
+ return info.changes;
429
+ },
430
+ supersedeEpisodesOfInactiveWatches: (now) => {
431
+ const info = db.prepare(`UPDATE episodes SET state='superseded', revision=revision+1, updated_at=?
432
+ WHERE state IN ('open','snoozed','acknowledged')
433
+ AND watch_id IN (SELECT watch_id FROM watches WHERE lifecycle IN ('closed','expired'))`).run(now);
434
+ return info.changes;
435
+ },
436
+ expireStaleAttentions: (now) => {
437
+ const info = db.prepare(`UPDATE outbox SET admission='expired',
438
+ admission_json=json_set(admission_json, '$.note', 'attention ttl elapsed before delivery; no longer deliverable'),
439
+ updated_at=?
440
+ WHERE event_type='watcher.attention.v1' AND admission IN ('pending','publishing') AND valid_until < ?`).run(now, now);
441
+ return info.changes;
442
+ },
443
+ insertJudgment: (input) => {
444
+ db.prepare(`INSERT INTO judgments (judgment_id,watch_id,generation,mission_revision,control_revision,window_digest,model,question_set,status,data_json,created_at)
445
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
446
+ .run(input.judgmentId, input.watchId, input.generation, input.missionRevision, input.controlRevision, input.windowDigest, input.model, input.questionSet, input.status, JSON.stringify(input.data), input.createdAt);
447
+ },
448
+ getJudgmentByWindow: (watchId, generation, windowDigest, model, questionSet) => {
449
+ const r = db.prepare(`SELECT judgment_id,status,data_json FROM judgments
450
+ WHERE watch_id=? AND generation=? AND window_digest=? AND model=? AND question_set=?
451
+ ORDER BY created_at DESC LIMIT 1`)
452
+ .get(watchId, generation, windowDigest, model, questionSet);
453
+ if (!r)
454
+ return undefined;
455
+ return { judgmentId: r.judgment_id, status: r.status, data: JSON.parse(r.data_json) };
456
+ },
457
+ listJudgments: (watchId, limit) => {
458
+ const rows = db.prepare(`SELECT judgment_id,window_digest,model,question_set,status,data_json,created_at FROM judgments WHERE watch_id=? ORDER BY created_at DESC LIMIT ?`)
459
+ .all(watchId, limit);
460
+ return rows.map(r => ({
461
+ judgmentId: r.judgment_id,
462
+ windowDigest: r.window_digest,
463
+ model: r.model,
464
+ questionSet: r.question_set,
465
+ status: r.status,
466
+ data: JSON.parse(r.data_json),
467
+ createdAt: r.created_at
468
+ }));
469
+ },
470
+ insertProbeRun: (input) => {
471
+ db.prepare(`INSERT INTO probe_runs (probe_run_id,watch_id,episode_id,generation,control_revision,probe_id,status,data_json,created_at)
472
+ VALUES (?,?,?,?,?,?,?,?,?)`)
473
+ .run(input.probeRunId, input.watchId, input.episodeId, input.generation, input.controlRevision, input.probeId, input.status, JSON.stringify(input.data), input.createdAt);
474
+ },
475
+ setProbeRunStatus: (probeRunId, status, data) => {
476
+ db.prepare('UPDATE probe_runs SET status=?, data_json=? WHERE probe_run_id=?')
477
+ .run(status, JSON.stringify(data), probeRunId);
478
+ },
479
+ countBudgetReservations: (category, periodKey, watchId) => {
480
+ const r = (watchId
481
+ ? db.prepare(`SELECT COUNT(*) AS n FROM budget_reservations WHERE category=? AND period_key=? AND watch_id=? AND state IN ('reserved','spent','unknown-cost')`)
482
+ .get(category, periodKey, watchId)
483
+ : db.prepare(`SELECT COUNT(*) AS n FROM budget_reservations WHERE category=? AND period_key=? AND state IN ('reserved','spent','unknown-cost')`)
484
+ .get(category, periodKey));
485
+ return r.n;
486
+ },
487
+ insertBudgetReservation: (input) => {
488
+ db.prepare(`INSERT INTO budget_reservations (reservation_id,watch_id,category,period_key,units,state,created_at)
489
+ VALUES (?,?,?,?,?,?,?)`)
490
+ .run(input.reservationId, input.watchId, input.category, input.periodKey, input.units, input.state, input.createdAt);
491
+ },
492
+ setBudgetReservationState: (reservationId, state) => {
493
+ db.prepare('UPDATE budget_reservations SET state=? WHERE reservation_id=?').run(state, reservationId);
494
+ },
495
+ getRelayCursor: (streamId) => {
496
+ const r = db.prepare('SELECT cursor FROM relay_cursors WHERE stream_id=?').get(streamId);
497
+ return r?.cursor;
498
+ },
499
+ putRelayCursor: (streamId, cursor) => {
500
+ db.prepare('INSERT INTO relay_cursors (stream_id,cursor) VALUES (?,?) ON CONFLICT(stream_id) DO UPDATE SET cursor=excluded.cursor')
501
+ .run(streamId, cursor);
502
+ }
503
+ };
504
+ return tx;
505
+ }
506
+ }
507
+ /** Actor scoping removed: actorId is passed explicitly to persistCommandResult. */
508
+ function rowToWatchRow(r) {
509
+ return {
510
+ watchId: r.watch_id,
511
+ generation: r.generation,
512
+ missionRevision: r.mission_revision,
513
+ controlRevision: r.control_revision,
514
+ lifecycle: r.lifecycle,
515
+ health: r.health,
516
+ ownerSession: r.owner_session,
517
+ ownerBindingEpoch: r.owner_binding_epoch,
518
+ spec: JSON.parse(r.spec_json),
519
+ snapshot: { ...emptySnapshot(), ...JSON.parse(r.snapshot_json) },
520
+ observationSeq: r.observation_seq,
521
+ nextDueAt: r.next_due_at ?? null,
522
+ updatedAt: r.updated_at
523
+ };
524
+ }
525
+ function rowToEpisodeRow(r) {
526
+ return {
527
+ episodeId: r.episode_id,
528
+ watchId: r.watch_id,
529
+ generation: r.generation,
530
+ kind: r.kind,
531
+ checkpointId: r.checkpoint_id,
532
+ ordinal: r.ordinal,
533
+ revision: r.revision,
534
+ state: r.state,
535
+ body: JSON.parse(r.body_json),
536
+ snoozeUntil: r.snooze_until ?? null,
537
+ updatedAt: r.updated_at
538
+ };
539
+ }
540
+ function rowToOutboxRow(r) {
541
+ return {
542
+ eventId: r.event_id,
543
+ watchId: r.watch_id,
544
+ episodeId: r.episode_id ?? null,
545
+ generation: r.generation,
546
+ eventType: r.event_type,
547
+ eventBytes: r.event_bytes,
548
+ eventDigest: r.event_digest,
549
+ validUntil: r.valid_until,
550
+ admission: r.admission,
551
+ admissionJson: JSON.parse(r.admission_json),
552
+ createdAt: r.created_at,
553
+ updatedAt: r.updated_at
554
+ };
555
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Clock port per contracts/interfaces.ts.
3
+ * wallNow: persisted wall-clock ms (basis across restarts).
4
+ * monotonicNow: same-process monotonic ms (same-process elapsed).
5
+ */
6
+ export interface Clock {
7
+ wallNow(): number;
8
+ monotonicNow(): number;
9
+ }
10
+ export declare class SystemClock implements Clock {
11
+ wallNow(): number;
12
+ monotonicNow(): number;
13
+ }
14
+ /** Injectable fake clock for tests. */
15
+ export declare class ManualClock implements Clock {
16
+ private _wall;
17
+ private _mono;
18
+ constructor(wall?: number, mono?: number);
19
+ wallNow(): number;
20
+ monotonicNow(): number;
21
+ advanceWall(ms: number): void;
22
+ advanceMono(ms: number): void;
23
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Clock port per contracts/interfaces.ts.
3
+ * wallNow: persisted wall-clock ms (basis across restarts).
4
+ * monotonicNow: same-process monotonic ms (same-process elapsed).
5
+ */
6
+ export class SystemClock {
7
+ wallNow() {
8
+ return Date.now();
9
+ }
10
+ monotonicNow() {
11
+ return performance.now();
12
+ }
13
+ }
14
+ /** Injectable fake clock for tests. */
15
+ export class ManualClock {
16
+ _wall;
17
+ _mono;
18
+ constructor(wall = Date.now(), mono = 0) {
19
+ this._wall = wall;
20
+ this._mono = mono;
21
+ }
22
+ wallNow() {
23
+ return this._wall;
24
+ }
25
+ monotonicNow() {
26
+ return this._mono;
27
+ }
28
+ advanceWall(ms) {
29
+ this._wall += ms;
30
+ }
31
+ advanceMono(ms) {
32
+ this._mono += ms;
33
+ }
34
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * IDs and digests. watchId is globally random; digest is sha256 hex.
3
+ */
4
+ export declare function newId(prefix: string): string;
5
+ export declare function sha256Hex(data: string | Uint8Array): string;
6
+ export declare function digestJson(value: unknown): string;
7
+ /** Command digest for idempotency: requestId + digest is unique. */
8
+ export declare function stableStringify(value: unknown): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * IDs and digests. watchId is globally random; digest is sha256 hex.
3
+ */
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ export function newId(prefix) {
6
+ return `${prefix}-${randomUUID().replace(/-/g, '').slice(0, 20)}`;
7
+ }
8
+ export function sha256Hex(data) {
9
+ return createHash('sha256').update(data).digest('hex');
10
+ }
11
+ export function digestJson(value) {
12
+ return sha256Hex(JSON.stringify(value));
13
+ }
14
+ /** Command digest for idempotency: requestId + digest is unique. */
15
+ export function stableStringify(value) {
16
+ return JSON.stringify(sortValue(value));
17
+ }
18
+ function sortValue(value) {
19
+ if (Array.isArray(value))
20
+ return value.map(sortValue);
21
+ if (value && typeof value === 'object') {
22
+ const out = {};
23
+ for (const k of Object.keys(value).sort()) {
24
+ out[k] = sortValue(value[k]);
25
+ }
26
+ return out;
27
+ }
28
+ return value;
29
+ }