@astrosheep/square 0.3.71 → 0.3.73

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 (51) hide show
  1. package/dist/artifact.d.ts +0 -3
  2. package/dist/artifact.js +5 -59
  3. package/dist/catch-decisions.d.ts +7 -1
  4. package/dist/catch-decisions.js +21 -8
  5. package/dist/claude-delivery.d.ts +1 -1
  6. package/dist/claude-delivery.js +7 -3
  7. package/dist/cli/ledger-command.d.ts +12 -0
  8. package/dist/cli/ledger-command.js +68 -0
  9. package/dist/cli/registry.js +2 -0
  10. package/dist/help.js +7 -2
  11. package/dist/host-ledger-file-adapter.d.ts +4 -10
  12. package/dist/host-ledger-file-adapter.js +238 -198
  13. package/dist/host-ledger-migration.d.ts +26 -0
  14. package/dist/host-ledger-migration.js +227 -0
  15. package/dist/host-ledger-sqlite.d.ts +62 -0
  16. package/dist/host-ledger-sqlite.js +341 -0
  17. package/dist/host-ledger.d.ts +13 -4
  18. package/dist/ledger-maintenance.d.ts +30 -0
  19. package/dist/ledger-maintenance.js +110 -0
  20. package/dist/packages/agent-delivery/src/claude.d.ts +2 -11
  21. package/dist/packages/agent-delivery/src/index.d.ts +2 -42
  22. package/dist/packages/agent-delivery/src/opencode.d.ts +2 -11
  23. package/dist/packages/agent-delivery/src/paseo.d.ts +2 -13
  24. package/dist/packages/agent-delivery/src/pi-client.d.ts +2 -9
  25. package/dist/packages/agent-delivery/src/types.d.ts +42 -0
  26. package/dist/participant-host.d.ts +6 -3
  27. package/dist/participant-host.js +44 -38
  28. package/dist/ports.d.ts +7 -1
  29. package/dist/registry.d.ts +2 -0
  30. package/dist/registry.js +1 -1
  31. package/dist/routes.js +1 -10
  32. package/dist/runtime.d.ts +2 -1
  33. package/dist/runtime.js +12 -6
  34. package/dist/sqlite-transaction.d.ts +5 -0
  35. package/dist/sqlite-transaction.js +72 -0
  36. package/dist/square-actions.js +26 -22
  37. package/dist/square-application.js +2 -14
  38. package/dist/square-migration.js +2 -1
  39. package/dist/square-sqlite.d.ts +2 -2
  40. package/dist/square-sqlite.js +15 -4
  41. package/guides/catch-contention-performance.md +157 -0
  42. package/guides/change-waits.md +1 -1
  43. package/guides/host-ledger.md +53 -6
  44. package/guides/indexed-host-ledger-performance.md +39 -0
  45. package/guides/postcommit-simplification-performance.md +36 -0
  46. package/package.json +1 -1
  47. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  48. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  49. package/plugins/paseo/client/square-message.tsx +51 -40
  50. package/plugins/paseo/shared/message.ts +1 -0
  51. package/plugins/pi/square-pi.js +2 -1
@@ -1,16 +1,16 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { isDeepStrictEqual } from 'node:util';
5
5
  import { withFileLock as acquireFileLock } from './file-lock.js';
6
6
  import { nameKey } from './model.js';
7
7
  import { formatActivityId } from './square-core.js';
8
8
  import { hostLedgerRoot } from './host-ledger-root.js';
9
- import { observeFileMetadata } from './file-changes.js';
9
+ import { observeFileMetadata, observeFileVersion } from './file-changes.js';
10
10
  import { canonicalPath, canonicalPathSync } from './canonical-path.js';
11
+ import { LEDGER_FILENAME, LEDGER_RETENTION, withLedger, readLedgerRevisions } from './host-ledger-sqlite.js';
11
12
  const LOCK = { retryMs: 10 };
12
- const RETENTION = 7 * 86400000;
13
- // Prepare before locking: lock databases and their journals are local runtime data too.
13
+ // The claim fence deliberately spans asynchronous artifact/native work, never a ledger transaction.
14
14
  async function withFileLock(file, options, fn) {
15
15
  if (options.signal?.aborted)
16
16
  throw options.signal.reason ?? new Error('File lock acquisition aborted');
@@ -23,75 +23,100 @@ async function withFileLock(file, options, fn) {
23
23
  if (error.code !== 'EEXIST')
24
24
  throw error;
25
25
  }
26
- return acquireFileLock(file, options, fn);
27
- }
28
- async function read(file, now, includeFuture = false) { try {
29
- return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
30
- const parsed = JSON.parse(line);
31
- const row = decodeWakeRecord(parsed);
32
- const at = row.at ?? row.updatedAt;
33
- return row.v === 1 && typeof at === 'number' && (durableWake(row) || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
34
- }
35
- catch {
36
- return [];
37
- } });
38
- }
39
- catch (e) {
40
- if (e.code === 'ENOENT')
41
- return [];
42
- throw e;
43
- } }
44
- /** Keep the old ledger intact while Windows readers briefly deny replacement.
45
- * The owning file lock stays held throughout; never unlink the destination. */
46
- async function write(file, rows, signal) {
47
- await fs.mkdir(path.dirname(file), { recursive: true });
48
- const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
49
26
  try {
50
- if (signal?.aborted)
51
- throw signal.reason ?? new Error('Ledger write aborted');
52
- await fs.writeFile(tmp, rows.length ? rows.map((row) => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600, flag: 'wx' });
53
- for (let attempt = 0;; attempt += 1) {
54
- if (signal?.aborted)
55
- throw signal.reason ?? new Error('Ledger write aborted');
56
- try {
57
- await fs.rename(tmp, file);
58
- return;
59
- }
60
- catch (error) {
61
- const code = error.code;
62
- if (attempt >= 5 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
63
- throw error;
64
- await sleep(10 * 2 ** attempt, undefined, { signal });
65
- }
66
- }
27
+ return await acquireFileLock(file, options, fn);
67
28
  }
68
- finally {
69
- await fs.rm(tmp, { force: true }).catch(() => undefined);
29
+ catch (error) {
30
+ if (options.signal?.aborted)
31
+ throw options.signal.reason ?? error;
32
+ throw error;
70
33
  }
71
34
  }
72
- function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
35
+ function identity(v) {
36
+ return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]);
37
+ }
73
38
  function durableWake(record) {
74
39
  const row = record;
75
- return row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown' || row.outcome === 'dispatching');
40
+ return row.kind === 'wake' && (['accepted', 'unknown', 'dispatching'].includes(row.outcome ?? '') || row.nativeDelivery?.harness === 'claude');
76
41
  }
77
- function decodeWakeRecord(row) {
78
- return row.kind === 'wake' && row.outcome === 'unknown' && row.unknownSource === undefined
79
- ? { ...row, unknownSource: row.signature === 'worker_interrupted_during_dispatch' ? 'interrupted' : 'transport' } : row;
80
- }
81
- /** Keep identity and native receipt, never a completed attempt's lease. */
82
42
  function finishWake(current, outcome, details = {}) {
83
43
  const { expiresAt: _expiresAt, ownerPid: _ownerPid, unknownSource: _unknownSource, ...rest } = current;
84
- return { ...rest, ...details, kind: 'wake', outcome,
85
- ...(outcome === 'unknown' ? { unknownSource: details.unknownSource ?? 'transport' } : {}) };
44
+ return { ...rest, ...details, kind: 'wake', outcome, ...(outcome === 'unknown' ? { unknownSource: details.unknownSource ?? 'transport' } : {}) };
86
45
  }
87
- function processAlive(pid) { if (pid === undefined)
88
- return undefined; try {
89
- process.kill(pid, 0);
90
- return true;
46
+ function processAlive(pid) {
47
+ if (pid === undefined)
48
+ return undefined;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ }
53
+ catch (error) {
54
+ return error.code === 'ESRCH' ? false : true;
55
+ }
91
56
  }
92
- catch (error) {
93
- return error.code === 'ESRCH' ? false : true;
94
- } }
57
+ const revisionReaders = new Map();
58
+ async function revisions(root, entry, signal) {
59
+ for (;;) {
60
+ const metadata = await entry.metadata.read(signal);
61
+ if (entry.cached?.metadata === metadata)
62
+ return entry.cached.revisions;
63
+ if (entry.pending) {
64
+ await entry.pending;
65
+ continue;
66
+ }
67
+ entry.pending = readLedgerRevisions(root, entry.controller.signal).then((value) => {
68
+ // Never associate a snapshot with metadata obtained after the read.
69
+ entry.cached = { metadata, revisions: value };
70
+ }).finally(() => { entry.pending = undefined; });
71
+ await entry.pending;
72
+ }
73
+ }
74
+ async function observeLedger(root) {
75
+ let shared = revisionReaders.get(root);
76
+ if (!shared) {
77
+ shared = observeFileMetadata(path.join(root, LEDGER_FILENAME)).then((metadata) => ({ metadata, refs: 0, controller: new AbortController() }));
78
+ revisionReaders.set(root, shared);
79
+ void shared.catch(() => { if (revisionReaders.get(root) === shared)
80
+ revisionReaders.delete(root); });
81
+ }
82
+ const entry = await shared;
83
+ // A previous last subscriber may have closed while this acquisition awaited metadata.
84
+ if (revisionReaders.get(root) !== shared)
85
+ return observeLedger(root);
86
+ entry.refs += 2;
87
+ const release = () => { if (--entry.refs === 0) {
88
+ entry.controller.abort(new Error('Host ledger monitor closed'));
89
+ entry.metadata.close();
90
+ revisionReaders.delete(root);
91
+ } };
92
+ async function category(kind) {
93
+ const observer = await observeFileVersion(path.join(root, LEDGER_FILENAME), `host-ledger-${kind}`, async (_file, signal) => {
94
+ const pair = await revisions(root, entry, signal);
95
+ return pair === undefined ? 'missing' : String(pair[kind]);
96
+ });
97
+ let closed = false;
98
+ return { get generation() { return observer.generation; }, hinted: observer.hinted, read: observer.read, changed: observer.changed,
99
+ close() { if (!closed) {
100
+ closed = true;
101
+ observer.close();
102
+ release();
103
+ } } };
104
+ }
105
+ let presence;
106
+ try {
107
+ presence = await category('presence');
108
+ return { presence, evidence: await category('evidence') };
109
+ }
110
+ catch (error) {
111
+ if (presence)
112
+ presence.close();
113
+ else
114
+ release();
115
+ release();
116
+ throw error;
117
+ }
118
+ }
119
+ /** Existing port surface; each operation owns a short-lived SQLite connection. */
95
120
  export class FileHostLedgerPort {
96
121
  root;
97
122
  clock;
@@ -99,38 +124,26 @@ export class FileHostLedgerPort {
99
124
  this.root = canonicalPathSync(o.rootPath ?? hostLedgerRoot());
100
125
  this.clock = o.now ?? Date.now;
101
126
  }
102
- file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
103
- /** Invalidation only: callers still use the ledger projections as their authority. */
104
- async observeChanges() {
105
- const presence = await observeFileMetadata(this.file('presence'));
106
- try {
107
- return { presence, evidence: await observeFileMetadata(this.file('evidence')) };
108
- }
109
- catch (error) {
110
- presence.close();
111
- throw error;
112
- }
113
- }
114
- /** Time can expire bindings/evidence even when their files do not change. */
127
+ observeChanges() { return observeLedger(this.root); }
115
128
  async nextExpiry(session) {
116
129
  const now = this.clock();
117
- const rows = [...await read(this.file('presence'), now, true),
118
- ...await read(this.file('evidence'), now, true)].filter((row) => row.session === session);
119
- let next = Infinity;
120
- for (const row of rows) {
121
- const at = 'updatedAt' in row ? row.updatedAt : 'at' in row ? row.at : undefined;
122
- if (typeof at === 'number') {
123
- if (at > now)
124
- next = Math.min(next, at);
125
- if (at + RETENTION + 1 > now && !durableWake(row))
126
- next = Math.min(next, at + RETENTION + 1);
130
+ return withLedger(this.root, false, (tx) => {
131
+ const records = [...tx.presence({ session }, now, true), ...tx.evidence({ session, includeReleased: true }, now, true)];
132
+ let next = Infinity;
133
+ for (const { record } of records) {
134
+ const at = 'updatedAt' in record ? record.updatedAt : 'at' in record ? record.at : undefined;
135
+ if (typeof at === 'number') {
136
+ if (at > now)
137
+ next = Math.min(next, at);
138
+ if (at + LEDGER_RETENTION + 1 > now && !durableWake(record))
139
+ next = Math.min(next, at + LEDGER_RETENTION + 1);
140
+ }
141
+ if ('expiresAt' in record && typeof record.expiresAt === 'number' && record.expiresAt > now)
142
+ next = Math.min(next, record.expiresAt);
127
143
  }
128
- if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
129
- next = Math.min(next, row.expiresAt);
130
- }
131
- return next;
144
+ return next;
145
+ }, Infinity);
132
146
  }
133
- /** The ownership claim critical section: the same lock as claimPresence and finalize. */
134
147
  withClaimLock(fn, signal) {
135
148
  return withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, fn);
136
149
  }
@@ -138,20 +151,15 @@ export class FileHostLedgerPort {
138
151
  const location = await canonicalPath(i.location);
139
152
  const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
140
153
  try {
141
- return await this.withClaimLock(async () => {
142
- const file = this.file('presence');
143
- // The authoritative read, ownership check, and write share the presence file lock:
144
- // a concurrent ensurePresence/removePresence between them must never be overwritten
145
- // by a stale snapshot.
146
- return withFileLock(file + '.lock', { ...LOCK, signal }, async () => {
147
- const rows = await read(file, this.clock(), true);
148
- const existing = rows.filter((row) => row.location === location && nameKey(row.participant) === nameKey(record.participant)).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
149
- if (existing !== undefined)
150
- return existing.session === record.session ? { status: 'owned', record: existing } : { status: 'busy', record: existing };
151
- await write(file, [...rows, record], signal);
152
- return { status: 'acquired', record };
153
- });
154
- }, signal);
154
+ return await this.withClaimLock(() => withLedger(this.root, true, (tx) => {
155
+ const existing = tx.presence({ location, participant: record.participant }, this.clock(), true)
156
+ .map(({ record }) => record).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
157
+ if (existing !== undefined)
158
+ return { status: existing.session === record.session ? 'owned' : 'busy', record: existing };
159
+ tx.publish('presence', this.clock());
160
+ tx.insert('presence', record);
161
+ return { status: 'acquired', record };
162
+ }, { status: 'degraded', record, error: new Error('Missing writable host ledger') }, signal), signal);
155
163
  }
156
164
  catch (error) {
157
165
  return { status: 'degraded', record, error };
@@ -160,116 +168,109 @@ export class FileHostLedgerPort {
160
168
  async ensurePresence(i) {
161
169
  const record = { ...i, location: await canonicalPath(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
162
170
  try {
163
- await withFileLock(this.file('presence') + '.lock', LOCK, async () => {
164
- const rows = await read(this.file('presence'), this.clock(), true);
165
- const current = rows.findLast((row) => k(row) === k(record));
166
- const sameEpoch = current?.epoch === record.epoch;
167
- const cancelledThrough = sameEpoch ? Math.max(current?.cancelledThrough ?? -1, record.cancelledThrough ?? -1) : record.cancelledThrough ?? -1;
168
- await write(this.file('presence'), [...rows.filter((row) => k(row) !== k(record)), { ...record, ...(cancelledThrough < 0 ? {} : { cancelledThrough }) }]);
169
- });
171
+ await withLedger(this.root, true, (tx) => {
172
+ const rows = tx.presence({ location: record.location, participant: record.participant, session: record.session, channel: record.channel }, this.clock(), true).filter((row) => identity(row.record) === identity(record));
173
+ const current = rows.at(-1)?.record;
174
+ const cancelledThrough = current?.epoch === record.epoch ? Math.max(current?.cancelledThrough ?? -1, record.cancelledThrough ?? -1) : record.cancelledThrough ?? -1;
175
+ tx.publish('presence', this.clock());
176
+ tx.remove('presence', rows);
177
+ tx.insert('presence', { ...record, ...(cancelledThrough < 0 ? {} : { cancelledThrough }) });
178
+ }, undefined);
170
179
  return { status: 'ensured', record };
171
180
  }
172
181
  catch (error) {
173
182
  return { status: 'degraded', record, error };
174
183
  }
175
184
  }
176
- /** Local batch suppression is monotonic only for the captured host generation. */
177
185
  async suppressPresence(i, through) {
178
186
  const record = { ...i, location: await canonicalPath(i.location) };
179
- const file = this.file('presence');
180
- return withFileLock(file + '.lock', LOCK, async () => {
181
- const rows = await read(file, this.clock(), true);
182
- const current = rows.findLast((row) => k(row) === k(record) && row.epoch === record.epoch);
187
+ return withLedger(this.root, true, (tx) => {
188
+ const current = tx.presence({ location: record.location, participant: record.participant, session: record.session, channel: record.channel }, this.clock(), true).findLast((row) => identity(row.record) === identity(record) && row.record.epoch === record.epoch);
183
189
  if (!current)
184
190
  return false;
185
- await write(file, rows.map((row) => row === current ? { ...row, cancelledThrough: Math.max(row.cancelledThrough ?? -1, through) } : row));
191
+ tx.publish('presence', this.clock());
192
+ tx.replace('presence', current.seq, { ...current.record, cancelledThrough: Math.max(current.record.cancelledThrough ?? -1, through) });
186
193
  return true;
187
- });
194
+ }, false);
188
195
  }
189
196
  async removePresence(i) {
190
197
  const record = { ...i, location: await canonicalPath(i.location) };
191
- await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), (await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record))));
198
+ await withLedger(this.root, true, (tx) => {
199
+ const rows = tx.presence({ location: record.location, participant: record.participant, session: record.session, channel: record.channel }, this.clock(), true).filter((row) => identity(row.record) === identity(record));
200
+ tx.publish('presence', this.clock());
201
+ tx.remove('presence', rows);
202
+ }, undefined);
192
203
  }
193
204
  async removePresenceIfUnchanged(i) {
194
205
  const record = { ...i, location: await canonicalPath(i.location) };
195
- return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
196
- const file = this.file('presence');
197
- const rows = await read(file, this.clock(), true);
198
- const matches = (row) => k(row) === k(record)
199
- && (row.epoch ?? undefined) === (record.epoch ?? undefined)
200
- && (row.updatedAt ?? undefined) === (record.updatedAt ?? undefined);
201
- const current = rows.findLast(matches);
202
- if (current === undefined)
206
+ return withLedger(this.root, true, (tx) => {
207
+ const current = tx.presence({ location: record.location, participant: record.participant, session: record.session, channel: record.channel }, this.clock(), true).findLast(({ record: row }) => identity(row) === identity(record) && (row.epoch ?? undefined) === (record.epoch ?? undefined) && (row.updatedAt ?? undefined) === (record.updatedAt ?? undefined));
208
+ if (!current)
203
209
  return false;
204
- await write(file, rows.filter((row) => row !== current));
210
+ tx.publish('presence', this.clock());
211
+ tx.remove('presence', [current]);
205
212
  return true;
206
- });
213
+ }, false);
207
214
  }
208
215
  async listPresence(i = {}) {
209
216
  const location = i.location === undefined ? undefined : await canonicalPath(i.location);
210
- return (await read(this.file('presence'), i.now ?? this.clock())).filter((row) => (!location || row.location === location) && (!i.participant || nameKey(row.participant) === nameKey(i.participant)) && (!i.session || row.session === i.session)).map(({ scope: _scope, ...row }) => row);
217
+ return withLedger(this.root, false, (tx) => tx.presence({ ...i, location, participant: i.participant || undefined, session: i.session || undefined }, i.now ?? this.clock()).map(({ record }) => {
218
+ const { scope: _scope, ...row } = record;
219
+ return row;
220
+ }), []);
211
221
  }
212
- /** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
213
222
  async claimWakeAttempt(i) {
214
- const location = await canonicalPath(i.attention.squarePath);
215
- const activity = formatActivityId(i.attention.actIndex);
216
- const at = i.now ?? this.clock();
217
- const file = this.file('evidence');
223
+ const location = await canonicalPath(i.attention.squarePath), activity = formatActivityId(i.attention.actIndex), at = i.now ?? this.clock();
218
224
  try {
219
- return await withFileLock(file + '.lock', LOCK, async () => {
220
- const all = await read(file, at, true);
221
- const attention = all.filter((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(i.attention.recipient) && row.activity === activity);
222
- const terminal = attention.findLast((row) => row.outcome === 'accepted') ?? attention.findLast((row) => row.outcome === 'unknown');
225
+ return await withLedger(this.root, true, (tx) => {
226
+ const attention = tx.evidence({ location, participant: i.attention.recipient, activity, kind: 'wake', includeReleased: true }, at, true);
227
+ const terminal = attention.findLast(({ record }) => record.outcome === 'accepted') ?? attention.findLast(({ record }) => record.outcome === 'unknown');
223
228
  if (terminal)
224
- return { status: 'terminal', record: terminal };
225
- const interrupted = attention.filter((row) => row.outcome === 'dispatching');
226
- const busy = interrupted.find((row) => (row.expiresAt ?? 0) > at && processAlive(row.ownerPid) !== false);
229
+ return { status: 'terminal', record: terminal.record };
230
+ const interrupted = attention.filter(({ record }) => record.outcome === 'dispatching');
231
+ const busy = interrupted.find(({ record }) => (record.expiresAt ?? 0) > at && processAlive(record.ownerPid) !== false);
227
232
  if (busy)
228
- return { status: 'busy', record: busy };
229
- const nextNumber = (session) => attention.filter((row) => row.session === session).reduce((highest, row) => Math.max(highest, row.attemptN ?? 0), 0) + 1;
230
- // An abandoned attempt may already have been sent, so it blocks every route.
233
+ return { status: 'busy', record: busy.record };
234
+ const nextNumber = (session) => attention.filter(({ record }) => record.session === session).reduce((highest, { record }) => Math.max(highest, record.attemptN ?? 0), 0) + 1;
235
+ tx.publish('evidence', at);
231
236
  if (interrupted.length) {
232
- const recovered = interrupted.map((row) => ({ ...finishWake(row, 'unknown', {
233
- unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch',
234
- message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
235
- }), routeKind: row.routeKind ?? i.routeKind, attemptN: row.attemptN ?? nextNumber(row.session), v: 1 }));
236
- const replacements = new Map(interrupted.map((row, index) => [row, recovered[index]]));
237
- await write(file, all.map((row) => replacements.get(row) ?? row));
237
+ const recovered = interrupted.map(({ record }) => ({ ...finishWake(record, 'unknown', {
238
+ unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch', message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
239
+ }), routeKind: record.routeKind ?? i.routeKind, attemptN: record.attemptN ?? nextNumber(record.session), v: 1 }));
240
+ interrupted.forEach((row, index) => tx.replace('evidence', row.seq, recovered[index]));
238
241
  return { status: 'terminal', record: recovered[0] };
239
242
  }
240
243
  const attemptN = nextNumber(i.session), claimToken = `${process.pid}-${at}-${randomUUID()}`;
241
- await write(file, [...all, { v: 1, at, location, participant: i.attention.recipient, session: i.session, activity,
242
- kind: 'wake', outcome: 'dispatching', routeKind: i.routeKind, attemptN, claimToken, ownerPid: process.pid, expiresAt: at + i.leaseMs }]);
244
+ tx.insert('evidence', { v: 1, at, location, participant: i.attention.recipient, session: i.session, activity, kind: 'wake', outcome: 'dispatching', routeKind: i.routeKind, attemptN, claimToken, ownerPid: process.pid, expiresAt: at + i.leaseMs });
243
245
  return { status: 'acquired', claimToken, attemptN };
244
- });
246
+ }, { status: 'degraded', error: new Error('Missing writable host ledger') });
245
247
  }
246
248
  catch (error) {
247
249
  return { status: 'degraded', error };
248
250
  }
249
251
  }
250
- /** One locked, token-fenced replacement; each operation owns its state condition. */
251
252
  async updateWake(input, update, at = this.clock()) {
252
253
  if (!input.claimToken)
253
254
  return false;
254
- const location = await canonicalPath(input.location), file = this.file('evidence');
255
- return withFileLock(file + '.lock', LOCK, async () => {
256
- const all = await read(file, at, true);
257
- const current = all.findLast((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(input.participant) && row.activity === input.activity && row.session === input.session && row.claimToken === input.claimToken);
258
- const next = current && update(current);
259
- if (!next)
255
+ const location = await canonicalPath(input.location);
256
+ return withLedger(this.root, true, (tx) => {
257
+ const current = tx.evidence({ location, participant: input.participant, activity: input.activity, session: input.session, kind: 'wake', claimToken: input.claimToken, includeReleased: true }, at, true).at(-1);
258
+ const next = current && update(current.record);
259
+ if (!next || !current)
260
260
  return false;
261
- if (next !== current)
262
- await write(file, all.map((row) => row === current ? { ...next, v: 1 } : row));
261
+ if (next !== current.record) {
262
+ tx.publish('evidence', at);
263
+ tx.replace('evidence', current.seq, { ...next, v: 1 });
264
+ }
263
265
  return true;
264
- });
266
+ }, false);
265
267
  }
266
268
  async listWakeAttempts(i = {}) {
267
269
  const attention = i.attention;
268
270
  return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canonicalPath(attention.squarePath), participant: attention.recipient, activity: formatActivityId(attention.actIndex) }), session: i.session, now: i.now });
269
271
  }
270
272
  async prepareNativeWake(i) {
271
- return this.updateWake(i, (current) => current.outcome === 'dispatching'
272
- ? { ...current, nativeDelivery: i.nativeDelivery, routeKind: i.routeKind, attemptN: i.attemptN } : undefined);
273
+ return this.updateWake(i, (current) => current.outcome === 'dispatching' ? { ...current, nativeDelivery: i.nativeDelivery, routeKind: i.routeKind, attemptN: i.attemptN } : undefined);
273
274
  }
274
275
  async confirmWakeAdmission(i) {
275
276
  return this.updateWake(i, (current) => {
@@ -278,55 +279,94 @@ export class FileHostLedgerPort {
278
279
  return current.outcome === 'accepted' ? current : finishWake(current, 'accepted', { signature: 'native_queue_confirmed', message: undefined, diagnostic: undefined });
279
280
  });
280
281
  }
281
- async claimEvidence(i) { if (i.kind === 'wake')
282
- return { status: 'degraded', error: new Error('wake attempts are claimed with claimWakeAttempt') }; const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, ...claim } = i, r = { ...claim, location: await canonicalPath(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
283
- return await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), matching = all.filter(x => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session), old = matching.findLast(x => x.outcome === 'dispatching'); if (old !== undefined && typeof old.expiresAt === 'number' && old.expiresAt > at)
284
- return { status: 'busy', record: old }; if (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented'))
285
- return { status: 'delivered', record: matching.findLast(x => x.outcome === 'presented') }; const claimToken = `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
282
+ async claimEvidence(i) {
283
+ if (i.kind === 'wake')
284
+ return { status: 'degraded', error: new Error('wake attempts are claimed with claimWakeAttempt') };
285
+ const at = i.now ?? this.clock(), { leaseMs, ...claim } = i;
286
+ const record = { ...claim, location: await canonicalPath(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 };
287
+ try {
288
+ return await withLedger(this.root, true, (tx) => {
289
+ const matching = tx.evidence({ ...record, includeReleased: true }, at, true);
290
+ const old = matching.findLast(({ record }) => record.outcome === 'dispatching');
291
+ if (old && typeof old.record.expiresAt === 'number' && old.record.expiresAt > at)
292
+ return { status: 'busy', record: old.record };
293
+ const presented = matching.findLast(({ record }) => record.outcome === 'presented');
294
+ if (record.kind === 'presentation' && presented)
295
+ return { status: 'delivered', record: presented.record };
296
+ const claimToken = `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`;
297
+ tx.publish('evidence', at);
298
+ tx.remove('evidence', matching.filter(({ record }) => record.outcome === 'dispatching' || record.outcome === 'released'));
299
+ tx.insert('evidence', { ...record, claimToken });
300
+ return { status: 'acquired', claimToken };
301
+ }, { status: 'degraded', error: new Error('Missing writable host ledger') });
302
+ }
303
+ catch (error) {
304
+ return { status: 'degraded', error };
305
+ }
286
306
  }
287
- catch (error) {
288
- return { status: 'degraded', error };
289
- } }
290
307
  async releaseEvidence(i) {
291
308
  if (i.kind === 'wake') {
292
309
  const { signature, message, diagnostic, routeKind, attemptN } = i;
293
310
  const details = Object.fromEntries(Object.entries({ signature, message, diagnostic, routeKind, attemptN }).filter(([, value]) => value !== undefined));
294
- await this.updateWake(i, (current) => current.outcome === 'dispatching'
295
- ? finishWake(current, 'released', { ...details, at: i.now ?? this.clock() }) : undefined, i.now);
311
+ await this.updateWake(i, (current) => current.outcome === 'dispatching' ? finishWake(current, 'released', { ...details, at: i.now ?? this.clock() }) : undefined, i.now);
296
312
  return;
297
313
  }
298
- const f = this.file('evidence'), location = await canonicalPath(i.location), at = i.now ?? this.clock();
299
- await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
300
- return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; delete tombstone.ownerPid; await write(f, [...all.filter(x => x !== current), tombstone]); });
314
+ const location = await canonicalPath(i.location), at = i.now ?? this.clock();
315
+ await withLedger(this.root, true, (tx) => {
316
+ const current = tx.evidence({ ...i, location, includeReleased: true, claimToken: undefined }, at, true).findLast(({ record }) => record.outcome === 'dispatching');
317
+ if (!current || current.record.claimToken !== i.claimToken)
318
+ return;
319
+ const { expiresAt: _expiry, ownerPid: _owner, ...rest } = current.record;
320
+ const details = Object.fromEntries(['routeKind', 'attemptN', 'signature', 'message', 'diagnostic'].filter((field) => i[field] !== undefined).map((field) => [field, i[field]]));
321
+ tx.publish('evidence', at);
322
+ tx.remove('evidence', [current]);
323
+ tx.insert('evidence', { ...rest, outcome: 'released', at, ...details });
324
+ }, undefined);
301
325
  }
302
- /** Token-fenced terminal write. A token may only finish the attempt it claimed. */
303
326
  async appendEvidence(i) {
304
- const f = this.file('evidence'), r = { ...i, location: await canonicalPath(i.location), at: i.at ?? this.clock(), v: 1 };
305
- const token = i.claimToken;
306
- if (typeof token !== 'string' || token.length === 0)
327
+ const record = { ...i, location: await canonicalPath(i.location), at: i.at ?? this.clock(), v: 1 };
328
+ if (typeof i.claimToken !== 'string' || !i.claimToken.length)
307
329
  return;
308
330
  if (i.kind === 'wake') {
309
- if (r.outcome !== 'accepted' && r.outcome !== 'failed' && r.outcome !== 'unknown')
331
+ if (!['accepted', 'failed', 'unknown'].includes(record.outcome))
310
332
  return;
311
- const outcome = r.outcome;
312
- await this.updateWake(r, (current) => {
333
+ const outcome = record.outcome;
334
+ await this.updateWake(record, (current) => {
313
335
  if (current.outcome !== 'dispatching' && !(current.outcome === 'unknown' && current.unknownSource === 'interrupted' && outcome === 'accepted'))
314
336
  return undefined;
315
- return finishWake(current, outcome, { at: r.at, routeKind: r.routeKind, attemptN: r.attemptN, signature: r.signature, message: r.message, diagnostic: r.diagnostic });
316
- }, r.at);
337
+ return finishWake(current, outcome, { at: record.at, routeKind: record.routeKind, attemptN: record.attemptN, signature: record.signature, message: record.message, diagnostic: record.diagnostic });
338
+ }, record.at);
317
339
  return;
318
340
  }
319
- await withFileLock(f + '.lock', LOCK, async () => {
320
- const all = await read(f, r.at, true);
321
- const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session;
322
- const dispatching = all.findLast((x) => same(x) && x.outcome === 'dispatching');
323
- const source = dispatching?.claimToken === token ? dispatching : undefined;
324
- if (source === undefined)
341
+ await withLedger(this.root, true, (tx) => {
342
+ const matching = tx.evidence({ ...record, claimToken: undefined, includeReleased: true }, record.at, true);
343
+ const source = matching.findLast(({ record }) => record.outcome === 'dispatching');
344
+ if (!source || source.record.claimToken !== record.claimToken)
325
345
  return;
326
- await write(f, [...all.filter((x) => x !== source && !(same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released')), { ...r, ...(source.nativeDelivery === undefined ? {} : { nativeDelivery: source.nativeDelivery }) }]);
327
- });
346
+ tx.publish('evidence', record.at);
347
+ tx.remove('evidence', matching.filter((row) => row === source || row.record.attemptN === record.attemptN && row.record.outcome !== 'dispatching' && row.record.outcome !== 'released'));
348
+ tx.insert('evidence', { ...record, ...(source.record.nativeDelivery === undefined ? {} : { nativeDelivery: source.record.nativeDelivery }) });
349
+ }, undefined);
350
+ }
351
+ async listEvidence(i = {}) {
352
+ const location = i.location === undefined ? undefined : await canonicalPath(i.location);
353
+ return withLedger(this.root, false, (tx) => tx.evidence({ ...i, location, participant: i.participant || undefined, session: i.session || undefined, activity: i.activity || undefined, kind: i.kind || undefined }, i.now ?? this.clock()).map(({ record }) => record), []);
354
+ }
355
+ async retireEvidenceIfUnchanged(expected) {
356
+ if (expected.outcome === 'dispatching')
357
+ return false;
358
+ // Compare the persisted coordinate verbatim; maintenance canonicalizes only its artifact proof.
359
+ return withLedger(this.root, true, (tx) => {
360
+ const current = tx.evidence({ location: expected.location, participant: expected.participant, session: expected.session, activity: expected.activity, kind: expected.kind,
361
+ claimToken: expected.claimToken, includeExpired: true, includeReleased: true }, this.clock(), true)
362
+ .find(({ record }) => isDeepStrictEqual(record, expected));
363
+ return current !== undefined && tx.retireEvidence(current);
364
+ }, false);
365
+ }
366
+ lookupNativeTokens(input) {
367
+ if (!input.tokens.length)
368
+ return Promise.resolve({ deliveries: [], retired: [] });
369
+ return withLedger(this.root, false, tx => tx.nativeTokens(input), { deliveries: [], retired: [] });
328
370
  }
329
- async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canonicalPath(i.location); return (await read(this.file('evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
330
- async gcEvidence(i) { const f = this.file('evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || durableWake(r) && pending.has(r.activity)))); }
331
371
  }
332
372
  export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }
@@ -0,0 +1,26 @@
1
+ type Kind = 'presence' | 'evidence';
2
+ export interface LedgerSourceReport {
3
+ records: number;
4
+ ignored: number;
5
+ rejected: number;
6
+ sha256: string | null;
7
+ }
8
+ export interface HostLedgerReport {
9
+ status: 'empty' | 'legacy' | 'sqlite' | 'migrated' | 'upgraded';
10
+ presence: number;
11
+ evidence: number;
12
+ presenceRevision?: number;
13
+ evidenceRevision?: number;
14
+ sources?: Record<Kind, LedgerSourceReport>;
15
+ ready?: boolean;
16
+ }
17
+ export interface HostLedgerMaintenanceOptions {
18
+ rootPath?: string;
19
+ env?: NodeJS.ProcessEnv;
20
+ signal?: AbortSignal;
21
+ }
22
+ /** Read-only readiness/integrity check. Never creates a root, lock, or database. */
23
+ export declare function verifyHostLedger(options?: HostLedgerMaintenanceOptions): Promise<HostLedgerReport>;
24
+ /** Offline only: locks fence compatible writers, but do not prove hosts have stopped. */
25
+ export declare function migrateHostLedger(options?: HostLedgerMaintenanceOptions): Promise<HostLedgerReport>;
26
+ export {};