@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.
- package/dist/artifact.d.ts +0 -3
- package/dist/artifact.js +5 -59
- package/dist/catch-decisions.d.ts +7 -1
- package/dist/catch-decisions.js +21 -8
- package/dist/claude-delivery.d.ts +1 -1
- package/dist/claude-delivery.js +7 -3
- package/dist/cli/ledger-command.d.ts +12 -0
- package/dist/cli/ledger-command.js +68 -0
- package/dist/cli/registry.js +2 -0
- package/dist/help.js +7 -2
- package/dist/host-ledger-file-adapter.d.ts +4 -10
- package/dist/host-ledger-file-adapter.js +238 -198
- package/dist/host-ledger-migration.d.ts +26 -0
- package/dist/host-ledger-migration.js +227 -0
- package/dist/host-ledger-sqlite.d.ts +62 -0
- package/dist/host-ledger-sqlite.js +341 -0
- package/dist/host-ledger.d.ts +13 -4
- package/dist/ledger-maintenance.d.ts +30 -0
- package/dist/ledger-maintenance.js +110 -0
- package/dist/packages/agent-delivery/src/claude.d.ts +2 -11
- package/dist/packages/agent-delivery/src/index.d.ts +2 -42
- package/dist/packages/agent-delivery/src/opencode.d.ts +2 -11
- package/dist/packages/agent-delivery/src/paseo.d.ts +2 -13
- package/dist/packages/agent-delivery/src/pi-client.d.ts +2 -9
- package/dist/packages/agent-delivery/src/types.d.ts +42 -0
- package/dist/participant-host.d.ts +6 -3
- package/dist/participant-host.js +44 -38
- package/dist/ports.d.ts +7 -1
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +1 -1
- package/dist/routes.js +1 -10
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +12 -6
- package/dist/sqlite-transaction.d.ts +5 -0
- package/dist/sqlite-transaction.js +72 -0
- package/dist/square-actions.js +26 -22
- package/dist/square-application.js +2 -14
- package/dist/square-migration.js +2 -1
- package/dist/square-sqlite.d.ts +2 -2
- package/dist/square-sqlite.js +15 -4
- package/guides/catch-contention-performance.md +157 -0
- package/guides/change-waits.md +1 -1
- package/guides/host-ledger.md +53 -6
- package/guides/indexed-host-ledger-performance.md +39 -0
- package/guides/postcommit-simplification-performance.md +36 -0
- package/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/.codex-plugin/plugin.json +1 -1
- package/plugins/paseo/client/square-message.tsx +51 -40
- package/plugins/paseo/shared/message.ts +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (options.signal?.aborted)
|
|
31
|
+
throw options.signal.reason ?? error;
|
|
32
|
+
throw error;
|
|
70
33
|
}
|
|
71
34
|
}
|
|
72
|
-
function
|
|
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' && (
|
|
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) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
...
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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(
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
164
|
-
const rows =
|
|
165
|
-
const current = rows.
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
196
|
-
const
|
|
197
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
220
|
-
const
|
|
221
|
-
const
|
|
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((
|
|
226
|
-
const busy = interrupted.find((
|
|
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((
|
|
230
|
-
|
|
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((
|
|
233
|
-
unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch',
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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)
|
|
255
|
-
return
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
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
|
-
|
|
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) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
299
|
-
await
|
|
300
|
-
|
|
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
|
|
305
|
-
|
|
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 (
|
|
331
|
+
if (!['accepted', 'failed', 'unknown'].includes(record.outcome))
|
|
310
332
|
return;
|
|
311
|
-
const outcome =
|
|
312
|
-
await this.updateWake(
|
|
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:
|
|
316
|
-
},
|
|
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
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
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
|
-
|
|
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 {};
|