@astrosheep/square 0.3.72 → 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/ports.d.ts +7 -1
- 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 +16 -8
- 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/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 +35 -19
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { canonicalPathSync } from './canonical-path.js';
|
|
6
|
+
import { withFileLock } from './file-lock.js';
|
|
7
|
+
import { hostLedgerRoot } from './host-ledger-root.js';
|
|
8
|
+
import { LEDGER_FILENAME, initializeLedger, insertLedgerRecord, ledgerRecordColumns, setLedgerRevisions, upgradeLedger, validateLedger, verifyLedger, } from './host-ledger-sqlite.js';
|
|
9
|
+
import { inSqliteTransaction } from './sqlite-transaction.js';
|
|
10
|
+
function rootFor(options) {
|
|
11
|
+
return canonicalPathSync(options.rootPath ?? hostLedgerRoot(options.env));
|
|
12
|
+
}
|
|
13
|
+
function abort(signal) {
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
throw signal.reason ?? new Error('Host ledger migration aborted');
|
|
16
|
+
}
|
|
17
|
+
async function exists(file) {
|
|
18
|
+
try {
|
|
19
|
+
await fs.lstat(file);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT')
|
|
24
|
+
return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Generic and payload-free: the only supported remedy is offline conversion under stopped hosts. */
|
|
29
|
+
function verificationFailure() {
|
|
30
|
+
return new Error('Host ledger database verification failed; destination left untouched.\nStop all Square hosts, then run: square ledger migrate');
|
|
31
|
+
}
|
|
32
|
+
async function verifyDatabase(file, signal) {
|
|
33
|
+
abort(signal);
|
|
34
|
+
let db;
|
|
35
|
+
try {
|
|
36
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
37
|
+
const counts = await inSqliteTransaction(db, false, () => validateLedger(db), () => verifyLedger(db), signal);
|
|
38
|
+
return { status: 'sqlite', ...counts };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
abort(signal);
|
|
42
|
+
throw verificationFailure();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
db?.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function readLegacy(root, kind, signal) {
|
|
49
|
+
let bytes;
|
|
50
|
+
try {
|
|
51
|
+
bytes = await fs.readFile(path.join(root, `${kind}.ndjsonl`));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return { rows: [], report: { records: 0, ignored: 0, rejected: 0, sha256: null } };
|
|
56
|
+
throw new Error(`Cannot read legacy ${kind} source.`);
|
|
57
|
+
}
|
|
58
|
+
const rows = [];
|
|
59
|
+
let ignored = 0, rejected = 0;
|
|
60
|
+
let firstRejectedLine;
|
|
61
|
+
const lines = bytes.toString('utf8').split('\n');
|
|
62
|
+
for (let index = 0; index < lines.length; index++) {
|
|
63
|
+
abort(signal);
|
|
64
|
+
if (lines[index].trim() === '')
|
|
65
|
+
continue;
|
|
66
|
+
let row;
|
|
67
|
+
try {
|
|
68
|
+
row = JSON.parse(lines[index]);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
ignored++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (row === null || typeof row !== 'object' || row.v !== 1 || typeof (row.at ?? row.updatedAt) !== 'number') {
|
|
75
|
+
ignored++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// This compatibility normalization belongs only to the explicit importer.
|
|
79
|
+
if (row.kind === 'wake' && row.outcome === 'unknown' && row.unknownSource === undefined) {
|
|
80
|
+
row = { ...row, unknownSource: row.signature === 'worker_interrupted_during_dispatch' ? 'interrupted' : 'transport' };
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
ledgerRecordColumns(kind, row);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
rejected++;
|
|
87
|
+
firstRejectedLine ??= index + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
rows.push(row);
|
|
91
|
+
}
|
|
92
|
+
return { rows, firstRejectedLine, report: { records: rows.length, ignored, rejected, sha256: createHash('sha256').update(bytes).digest('hex') } };
|
|
93
|
+
}
|
|
94
|
+
async function readSources(root, signal) {
|
|
95
|
+
return { presence: await readLegacy(root, 'presence', signal), evidence: await readLegacy(root, 'evidence', signal) };
|
|
96
|
+
}
|
|
97
|
+
function sourceReport(sources) {
|
|
98
|
+
return {
|
|
99
|
+
status: sources.presence.report.sha256 === null && sources.evidence.report.sha256 === null ? 'empty' : 'legacy',
|
|
100
|
+
presence: sources.presence.rows.length, evidence: sources.evidence.rows.length,
|
|
101
|
+
sources: { presence: sources.presence.report, evidence: sources.evidence.report },
|
|
102
|
+
ready: sources.presence.report.rejected === 0 && sources.evidence.report.rejected === 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Read-only readiness/integrity check. Never creates a root, lock, or database. */
|
|
106
|
+
export async function verifyHostLedger(options = {}) {
|
|
107
|
+
const root = rootFor(options);
|
|
108
|
+
abort(options.signal);
|
|
109
|
+
const destination = path.join(root, LEDGER_FILENAME);
|
|
110
|
+
if (await exists(destination))
|
|
111
|
+
return verifyDatabase(destination, options.signal);
|
|
112
|
+
return sourceReport(await readSources(root, options.signal));
|
|
113
|
+
}
|
|
114
|
+
/** The fixed offline fence: ownership claim, then both legacy source locks, in that order. */
|
|
115
|
+
function withOfflineLocks(root, signal, body) {
|
|
116
|
+
const lockOptions = { retryMs: 10, signal };
|
|
117
|
+
return withFileLock(path.join(root, 'presence-claim.lock'), lockOptions, () => withFileLock(path.join(root, 'presence.ndjsonl.lock'), lockOptions, () => withFileLock(path.join(root, 'evidence.ndjsonl.lock'), lockOptions, body)))
|
|
118
|
+
.catch((error) => { abort(signal); throw error; });
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Under the offline fence: a current ledger verifies read-only, an older SQLite format is upgraded in
|
|
122
|
+
* place through the explicit transaction owner, and anything else is refused without repair.
|
|
123
|
+
*/
|
|
124
|
+
async function currentFormat(root, signal) {
|
|
125
|
+
const destination = path.join(root, LEDGER_FILENAME);
|
|
126
|
+
try {
|
|
127
|
+
return await verifyDatabase(destination, signal);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
abort(signal);
|
|
131
|
+
}
|
|
132
|
+
if (!await exists(destination))
|
|
133
|
+
throw verificationFailure();
|
|
134
|
+
let db;
|
|
135
|
+
try {
|
|
136
|
+
db = new DatabaseSync(destination);
|
|
137
|
+
const upgraded = await inSqliteTransaction(db, true, () => { }, () => upgradeLedger(db), signal);
|
|
138
|
+
db.close();
|
|
139
|
+
db = undefined;
|
|
140
|
+
return { ...await verifyDatabase(destination, signal), status: upgraded ? 'upgraded' : 'sqlite' };
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
abort(signal);
|
|
144
|
+
throw verificationFailure();
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
db?.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Offline only: locks fence compatible writers, but do not prove hosts have stopped. */
|
|
151
|
+
export async function migrateHostLedger(options = {}) {
|
|
152
|
+
const root = rootFor(options), signal = options.signal;
|
|
153
|
+
abort(signal);
|
|
154
|
+
const destination = path.join(root, LEDGER_FILENAME);
|
|
155
|
+
if (await exists(destination)) {
|
|
156
|
+
try {
|
|
157
|
+
return await verifyDatabase(destination, signal);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
abort(signal);
|
|
161
|
+
}
|
|
162
|
+
// An older SQLite format is upgraded offline; a current ledger stays a read-only no-op.
|
|
163
|
+
return withOfflineLocks(root, signal, () => currentFormat(root, signal));
|
|
164
|
+
}
|
|
165
|
+
if (!await exists(path.join(root, 'presence.ndjsonl')) && !await exists(path.join(root, 'evidence.ndjsonl'))) {
|
|
166
|
+
return { status: 'empty', presence: 0, evidence: 0 };
|
|
167
|
+
}
|
|
168
|
+
return withOfflineLocks(root, signal, () => convertLegacy(root, destination, signal));
|
|
169
|
+
}
|
|
170
|
+
/** The offline NDJSON import: build, verify and publish a new database without touching sources. */
|
|
171
|
+
async function convertLegacy(root, destination, signal) {
|
|
172
|
+
if (await exists(destination))
|
|
173
|
+
return currentFormat(root, signal);
|
|
174
|
+
const sources = await readSources(root, signal);
|
|
175
|
+
const report = sourceReport(sources);
|
|
176
|
+
if (!report.ready) {
|
|
177
|
+
const rejected = ['presence', 'evidence'].filter((kind) => sources[kind].report.rejected > 0)
|
|
178
|
+
.map((kind) => `${kind}: ${sources[kind].report.rejected} rejected (first line ${sources[kind].firstRejectedLine})`);
|
|
179
|
+
throw new Error(`Unsafe legacy records; conversion refused. ${rejected.join('; ')}.`);
|
|
180
|
+
}
|
|
181
|
+
const temporary = await fs.mkdtemp(path.join(root, '.host-ledger-migrate-'));
|
|
182
|
+
const file = path.join(temporary, LEDGER_FILENAME);
|
|
183
|
+
let db;
|
|
184
|
+
try {
|
|
185
|
+
abort(signal);
|
|
186
|
+
// The temporary connection remains under the ledger root, including journals.
|
|
187
|
+
await fs.writeFile(file, '', { flag: 'wx', mode: 0o600 });
|
|
188
|
+
db = new DatabaseSync(file);
|
|
189
|
+
initializeLedger(db);
|
|
190
|
+
await inSqliteTransaction(db, true, () => { }, () => {
|
|
191
|
+
for (const kind of ['presence', 'evidence']) {
|
|
192
|
+
for (const row of sources[kind].rows) {
|
|
193
|
+
abort(signal);
|
|
194
|
+
insertLedgerRecord(db, kind, row);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
setLedgerRevisions(db, { presence: report.presence ? 1 : 0, evidence: report.evidence ? 1 : 0 });
|
|
198
|
+
}, signal);
|
|
199
|
+
db.close();
|
|
200
|
+
db = undefined;
|
|
201
|
+
const verified = await verifyDatabase(file, signal);
|
|
202
|
+
if (verified.presence !== report.presence || verified.evidence !== report.evidence) {
|
|
203
|
+
throw new Error('Host ledger import counts do not match; conversion refused.');
|
|
204
|
+
}
|
|
205
|
+
abort(signal);
|
|
206
|
+
try {
|
|
207
|
+
await fs.link(file, destination);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error.code === 'EEXIST')
|
|
211
|
+
return await verifyDatabase(destination, signal);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
const directory = await fs.open(root, 'r');
|
|
215
|
+
try {
|
|
216
|
+
await directory.sync();
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await directory.close();
|
|
220
|
+
}
|
|
221
|
+
return { ...verified, status: 'migrated', sources: report.sources };
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
db?.close();
|
|
225
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
|
|
2
|
+
import type { PresenceRecord, PresenceLookup, EvidenceRecord, EvidenceLookup, NativeTokenLookup, NativeTokenResult } from './host-ledger.js';
|
|
3
|
+
export declare const LEDGER_FILENAME = "host-ledger.sqlite";
|
|
4
|
+
export declare const LEDGER_APPLICATION_ID = 1397835852;
|
|
5
|
+
export declare const LEDGER_VERSION = 2;
|
|
6
|
+
export declare const LEDGER_RETENTION: number;
|
|
7
|
+
type Kind = 'presence' | 'evidence';
|
|
8
|
+
export interface Stored<T> {
|
|
9
|
+
readonly seq: number;
|
|
10
|
+
readonly record: T;
|
|
11
|
+
}
|
|
12
|
+
/** The complete record remains authority; these are only reproducible lookup projections. */
|
|
13
|
+
export declare function ledgerRecordColumns(kind: Kind, input: object): Record<string, SQLInputValue>;
|
|
14
|
+
export declare function initializeLedger(db: DatabaseSync): void;
|
|
15
|
+
export declare function setLedgerRevisions(db: DatabaseSync, revisions: {
|
|
16
|
+
presence: number;
|
|
17
|
+
evidence: number;
|
|
18
|
+
}): void;
|
|
19
|
+
export declare function insertLedgerRecord(db: DatabaseSync, kind: Kind, row: object): void;
|
|
20
|
+
export declare function ledgerRevisions(db: DatabaseSync): {
|
|
21
|
+
presence: number;
|
|
22
|
+
evidence: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function validateLedger(db: DatabaseSync): void;
|
|
25
|
+
export declare function verifyLedger(db: DatabaseSync): {
|
|
26
|
+
presence: number;
|
|
27
|
+
evidence: number;
|
|
28
|
+
presenceRevision: number;
|
|
29
|
+
evidenceRevision: number;
|
|
30
|
+
};
|
|
31
|
+
/** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
|
|
32
|
+
export declare function upgradeLedger(db: DatabaseSync): boolean;
|
|
33
|
+
export declare function migrationRequired(): Error;
|
|
34
|
+
/** Transaction-local storage rows; sequence coordinates never cross HostLedgerPort. */
|
|
35
|
+
export declare class LedgerTransaction {
|
|
36
|
+
private readonly db;
|
|
37
|
+
private readonly changed;
|
|
38
|
+
constructor(db: DatabaseSync);
|
|
39
|
+
presence(input: PresenceLookup & {
|
|
40
|
+
channel?: string;
|
|
41
|
+
}, now: number, includeFuture?: boolean): Stored<PresenceRecord>[];
|
|
42
|
+
evidence(input: EvidenceLookup & {
|
|
43
|
+
claimToken?: string;
|
|
44
|
+
}, now: number, includeFuture?: boolean): Stored<EvidenceRecord>[];
|
|
45
|
+
private select;
|
|
46
|
+
/** Both halves of recognition are read in the enclosing SQLite snapshot, with indexed session/token predicates. */
|
|
47
|
+
nativeTokens(input: NativeTokenLookup): NativeTokenResult;
|
|
48
|
+
/** Called only after exact-record CAS. No housekeeping of unrelated rows belongs here. */
|
|
49
|
+
retireEvidence(row: Stored<EvidenceRecord>): boolean;
|
|
50
|
+
insert(kind: Kind, row: object): void;
|
|
51
|
+
replace(kind: Kind, seq: number, row: object): void;
|
|
52
|
+
remove(kind: Kind, rows: readonly Stored<unknown>[]): void;
|
|
53
|
+
/** Only invoke at the point where the former implementation published a mutation. */
|
|
54
|
+
publish(kind: Kind, now: number): void;
|
|
55
|
+
commitRevisions(): void;
|
|
56
|
+
}
|
|
57
|
+
export declare function withLedger<T>(root: string, write: boolean, select: (rows: LedgerTransaction) => T, absent: T, signal?: AbortSignal): Promise<T>;
|
|
58
|
+
export declare function readLedgerRevisions(root: string, signal?: AbortSignal): Promise<{
|
|
59
|
+
presence: number;
|
|
60
|
+
evidence: number;
|
|
61
|
+
} | undefined>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { nameKey } from './model.js';
|
|
7
|
+
import { inSqliteTransaction, retrySqliteBusy } from './sqlite-transaction.js';
|
|
8
|
+
export const LEDGER_FILENAME = 'host-ledger.sqlite';
|
|
9
|
+
export const LEDGER_APPLICATION_ID = 0x5351484c;
|
|
10
|
+
export const LEDGER_VERSION = 2;
|
|
11
|
+
export const LEDGER_RETENTION = 7 * 86400000;
|
|
12
|
+
const tables = { presence: 'host_presence', evidence: 'host_evidence' };
|
|
13
|
+
const columns = {
|
|
14
|
+
presence: ['location_key', 'participant_key', 'session_key', 'channel_key', 'effective_at'],
|
|
15
|
+
evidence: ['location_key', 'participant_key', 'session_key', 'activity_key', 'kind', 'outcome', 'claim_token_key', 'effective_at'],
|
|
16
|
+
};
|
|
17
|
+
const indexes = {
|
|
18
|
+
host_presence_identity: ['host_presence', 'location_key', 'participant_key', 'seq'],
|
|
19
|
+
host_presence_session: ['host_presence', 'session_key', 'seq'],
|
|
20
|
+
host_presence_time: ['host_presence', 'effective_at'],
|
|
21
|
+
host_evidence_attention: ['host_evidence', 'location_key', 'participant_key', 'activity_key', 'kind', 'session_key', 'seq'],
|
|
22
|
+
host_evidence_kind_session: ['host_evidence', 'kind', 'session_key', 'seq'],
|
|
23
|
+
host_evidence_session: ['host_evidence', 'session_key', 'seq'],
|
|
24
|
+
host_evidence_token: ['host_evidence', 'location_key', 'participant_key', 'activity_key', 'session_key', 'claim_token_key', 'seq'],
|
|
25
|
+
host_evidence_outcome_time: ['host_evidence', 'outcome', 'effective_at'],
|
|
26
|
+
host_evidence_time: ['host_evidence', 'effective_at'],
|
|
27
|
+
host_evidence_native_token: ['host_evidence', 'session_key', 'claim_token_key', 'seq'],
|
|
28
|
+
};
|
|
29
|
+
const tableSchema = {
|
|
30
|
+
host_meta: 'CREATE TABLE host_meta (id INTEGER PRIMARY KEY CHECK(id=1), presence_revision INTEGER NOT NULL, evidence_revision INTEGER NOT NULL)',
|
|
31
|
+
host_presence: 'CREATE TABLE host_presence (seq INTEGER PRIMARY KEY AUTOINCREMENT, location_key TEXT NOT NULL, participant_key TEXT NOT NULL, session_key TEXT NOT NULL, channel_key TEXT NOT NULL, effective_at REAL NOT NULL, row_json TEXT NOT NULL)',
|
|
32
|
+
host_evidence: 'CREATE TABLE host_evidence (seq INTEGER PRIMARY KEY AUTOINCREMENT, location_key TEXT NOT NULL, participant_key TEXT NOT NULL, session_key TEXT NOT NULL, activity_key TEXT NOT NULL, kind TEXT NOT NULL, outcome TEXT NOT NULL, claim_token_key TEXT, effective_at REAL NOT NULL, row_json TEXT NOT NULL)',
|
|
33
|
+
host_native_tokens: "CREATE TABLE host_native_tokens (harness TEXT NOT NULL CHECK(harness='claude'), session_key TEXT NOT NULL, token_key TEXT NOT NULL, PRIMARY KEY(harness,session_key,token_key)) WITHOUT ROWID",
|
|
34
|
+
};
|
|
35
|
+
const versionTwoNames = new Set(['host_native_tokens', 'host_evidence_native_token']);
|
|
36
|
+
const retainedEvidence = "(kind='wake' AND (outcome IN ('accepted','unknown','dispatching') OR COALESCE(json_extract(row_json,'$.nativeDelivery.harness'),'')='claude'))";
|
|
37
|
+
function validToken(token) { return typeof token === 'string' && /^[a-zA-Z0-9-]+$/.test(token); }
|
|
38
|
+
function indexSql(name, [table, ...fields]) {
|
|
39
|
+
return `CREATE INDEX ${name} ON ${table} (${fields.join(',')})`;
|
|
40
|
+
}
|
|
41
|
+
function invalid(message) { return new Error(`Invalid host ledger: ${message}`); }
|
|
42
|
+
function key(value) { return JSON.stringify(value); }
|
|
43
|
+
/** The complete record remains authority; these are only reproducible lookup projections. */
|
|
44
|
+
export function ledgerRecordColumns(kind, input) {
|
|
45
|
+
const row = input;
|
|
46
|
+
if (!row || Array.isArray(row) || row.v !== 1)
|
|
47
|
+
throw invalid('record version');
|
|
48
|
+
for (const field of ['location', 'participant', 'session', ...(kind === 'presence' ? ['channel'] : ['activity', 'kind', 'outcome'])]) {
|
|
49
|
+
if (typeof row[field] !== 'string')
|
|
50
|
+
throw invalid(`record ${field}`);
|
|
51
|
+
}
|
|
52
|
+
if (kind === 'evidence' && row.claimToken !== undefined && typeof row.claimToken !== 'string')
|
|
53
|
+
throw invalid('record claimToken');
|
|
54
|
+
const at = row.at ?? row.updatedAt;
|
|
55
|
+
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
56
|
+
throw invalid('record time');
|
|
57
|
+
return {
|
|
58
|
+
location_key: key(row.location), participant_key: key(nameKey(row.participant)), session_key: key(row.session),
|
|
59
|
+
...(kind === 'presence' ? { channel_key: key(row.channel) } : {
|
|
60
|
+
activity_key: key(row.activity), kind: row.kind, outcome: row.outcome,
|
|
61
|
+
claim_token_key: row.claimToken === undefined ? null : key(row.claimToken),
|
|
62
|
+
}), effective_at: at,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function initializeLedger(db) {
|
|
66
|
+
db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL;
|
|
67
|
+
PRAGMA application_id=${LEDGER_APPLICATION_ID}; PRAGMA user_version=${LEDGER_VERSION};`);
|
|
68
|
+
initializeLedgerSchema(db);
|
|
69
|
+
}
|
|
70
|
+
export function setLedgerRevisions(db, revisions) {
|
|
71
|
+
if (![revisions.presence, revisions.evidence].every((n) => Number.isSafeInteger(n) && n >= 0))
|
|
72
|
+
throw invalid('revisions');
|
|
73
|
+
db.prepare('UPDATE host_meta SET presence_revision=?,evidence_revision=? WHERE id=1').run(revisions.presence, revisions.evidence);
|
|
74
|
+
}
|
|
75
|
+
export function insertLedgerRecord(db, kind, row) {
|
|
76
|
+
const derived = ledgerRecordColumns(kind, row);
|
|
77
|
+
db.prepare(`INSERT INTO ${tables[kind]} (${columns[kind].join(',')},row_json) VALUES (${columns[kind].map(() => '?').join(',')},?)`)
|
|
78
|
+
.run(...columns[kind].map((column) => derived[column]), JSON.stringify(row));
|
|
79
|
+
}
|
|
80
|
+
export function ledgerRevisions(db) {
|
|
81
|
+
const rows = db.prepare('SELECT id,presence_revision,evidence_revision FROM host_meta').all();
|
|
82
|
+
if (rows.length !== 1 || rows[0].id !== 1 || ![rows[0].presence_revision, rows[0].evidence_revision].every((n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0))
|
|
83
|
+
throw invalid('revisions');
|
|
84
|
+
return { presence: rows[0].presence_revision, evidence: rows[0].evidence_revision };
|
|
85
|
+
}
|
|
86
|
+
export function validateLedger(db) { validateLedgerAtVersion(db, LEDGER_VERSION); }
|
|
87
|
+
function validateLedgerAtVersion(db, version) {
|
|
88
|
+
validateLedgerHeader(db, version);
|
|
89
|
+
if (db.prepare('PRAGMA journal_mode').get()?.journal_mode !== 'delete')
|
|
90
|
+
throw invalid('journal mode');
|
|
91
|
+
const expected = new Map([...Object.entries(tableSchema), ...Object.entries(indexes).map(([name, fields]) => [name, indexSql(name, fields)])].filter(([name]) => version >= 2 || !versionTwoNames.has(name)));
|
|
92
|
+
const schema = db.prepare("SELECT name,sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all();
|
|
93
|
+
const normalize = (sql) => sql.replace(/\s+/g, '').replace(/;$/, '');
|
|
94
|
+
if (schema.length !== expected.size || schema.some((row) => typeof row.sql !== 'string' || !expected.has(row.name) || normalize(row.sql) !== normalize(expected.get(row.name))))
|
|
95
|
+
throw invalid('schema or indexes');
|
|
96
|
+
ledgerRevisions(db);
|
|
97
|
+
}
|
|
98
|
+
export function verifyLedger(db) {
|
|
99
|
+
return verifyLedgerAtVersion(db, LEDGER_VERSION);
|
|
100
|
+
}
|
|
101
|
+
function verifyLedgerAtVersion(db, version) {
|
|
102
|
+
validateLedgerAtVersion(db, version);
|
|
103
|
+
const integrity = db.prepare('PRAGMA integrity_check').all();
|
|
104
|
+
if (integrity.length !== 1 || integrity[0].integrity_check !== 'ok')
|
|
105
|
+
throw invalid('integrity check failed');
|
|
106
|
+
const counts = { presence: 0, evidence: 0 };
|
|
107
|
+
for (const kind of ['presence', 'evidence']) {
|
|
108
|
+
for (const stored of db.prepare(`SELECT * FROM ${tables[kind]} ORDER BY seq ASC`).iterate()) {
|
|
109
|
+
let row;
|
|
110
|
+
try {
|
|
111
|
+
row = JSON.parse(stored.row_json);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw invalid('record JSON');
|
|
115
|
+
}
|
|
116
|
+
const derived = ledgerRecordColumns(kind, row);
|
|
117
|
+
if (!Number.isSafeInteger(stored.seq) || stored.seq < 1 || columns[kind].some((column) => stored[column] !== derived[column]))
|
|
118
|
+
throw invalid('record projections');
|
|
119
|
+
counts[kind] += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (version >= 2) {
|
|
123
|
+
for (const row of db.prepare('SELECT harness,session_key,token_key FROM host_native_tokens').iterate()) {
|
|
124
|
+
try {
|
|
125
|
+
const session = JSON.parse(row.session_key), token = JSON.parse(row.token_key);
|
|
126
|
+
if (row.harness !== 'claude' || typeof session !== 'string' || !session.length || !validToken(token)
|
|
127
|
+
|| key(session) !== row.session_key || key(token) !== row.token_key)
|
|
128
|
+
throw invalid('native token codec');
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw invalid('native token codec');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const revision = ledgerRevisions(db);
|
|
136
|
+
if ((counts.presence && !revision.presence) || (counts.evidence && !revision.evidence))
|
|
137
|
+
throw invalid('records without revision');
|
|
138
|
+
return { ...counts, presenceRevision: revision.presence, evidenceRevision: revision.evidence };
|
|
139
|
+
}
|
|
140
|
+
/** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
|
|
141
|
+
export function upgradeLedger(db) {
|
|
142
|
+
if (db.prepare('PRAGMA user_version').get()?.user_version === LEDGER_VERSION) {
|
|
143
|
+
verifyLedger(db);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
verifyLedgerAtVersion(db, 1);
|
|
147
|
+
db.exec(tableSchema.host_native_tokens);
|
|
148
|
+
db.exec(indexSql('host_evidence_native_token', indexes.host_evidence_native_token));
|
|
149
|
+
db.exec(`PRAGMA user_version=${LEDGER_VERSION}`);
|
|
150
|
+
verifyLedger(db);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
function exists(file) { try {
|
|
154
|
+
fs.statSync(file);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === 'ENOENT')
|
|
159
|
+
return false;
|
|
160
|
+
throw error;
|
|
161
|
+
} }
|
|
162
|
+
export function migrationRequired() {
|
|
163
|
+
return new Error('Host ledger conversion required. Stop all ledger users before running:\nsquare ledger migrate');
|
|
164
|
+
}
|
|
165
|
+
/** Compact runtime framing. Full schema, projections and integrity belong to explicit verify. */
|
|
166
|
+
function validateLedgerHeader(db, version = LEDGER_VERSION) {
|
|
167
|
+
if (db.prepare('PRAGMA application_id').get()?.application_id !== LEDGER_APPLICATION_ID
|
|
168
|
+
|| db.prepare('PRAGMA user_version').get()?.user_version !== version)
|
|
169
|
+
throw invalid('unsupported identity or version. Stop all ledger users before running:\nsquare ledger migrate');
|
|
170
|
+
}
|
|
171
|
+
/** Publish new empty authority only. Never initialize or replace an existing destination. */
|
|
172
|
+
function createEmptyLedger(root) {
|
|
173
|
+
fs.mkdirSync(root, { recursive: true });
|
|
174
|
+
try {
|
|
175
|
+
fs.writeFileSync(path.join(root, '.gitignore'), '*\n', { flag: 'wx' });
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'EEXIST')
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
const temporary = path.join(root, `.host-ledger.${process.pid}.${randomUUID()}.tmp`);
|
|
182
|
+
let db;
|
|
183
|
+
try {
|
|
184
|
+
fs.closeSync(fs.openSync(temporary, 'wx', 0o600));
|
|
185
|
+
db = new DatabaseSync(temporary);
|
|
186
|
+
initializeLedger(db);
|
|
187
|
+
db.close();
|
|
188
|
+
db = undefined;
|
|
189
|
+
try {
|
|
190
|
+
fs.linkSync(temporary, path.join(root, LEDGER_FILENAME));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error.code !== 'EEXIST')
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
db?.close();
|
|
199
|
+
fs.rmSync(temporary, { force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Never create authority on a read, or silently replace legacy authority on a write. */
|
|
203
|
+
async function openLedger(root, write, signal) {
|
|
204
|
+
const file = path.join(root, LEDGER_FILENAME);
|
|
205
|
+
if (!exists(file)) {
|
|
206
|
+
if (exists(path.join(root, 'presence.ndjsonl')) || exists(path.join(root, 'evidence.ndjsonl')))
|
|
207
|
+
throw migrationRequired();
|
|
208
|
+
if (!write)
|
|
209
|
+
return undefined;
|
|
210
|
+
if (signal?.aborted)
|
|
211
|
+
throw signal.reason;
|
|
212
|
+
createEmptyLedger(root);
|
|
213
|
+
}
|
|
214
|
+
const uri = pathToFileURL(file);
|
|
215
|
+
uri.searchParams.set('mode', write ? 'rw' : 'ro');
|
|
216
|
+
const db = new DatabaseSync(uri.href);
|
|
217
|
+
try {
|
|
218
|
+
await retrySqliteBusy(() => db.exec('PRAGMA busy_timeout=0; PRAGMA synchronous=FULL'), signal);
|
|
219
|
+
return db;
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
db.close();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function initializeLedgerSchema(db) {
|
|
227
|
+
for (const sql of Object.values(tableSchema))
|
|
228
|
+
db.exec(sql);
|
|
229
|
+
db.exec('INSERT INTO host_meta VALUES(1,0,0)');
|
|
230
|
+
for (const [name, fields] of Object.entries(indexes))
|
|
231
|
+
db.exec(indexSql(name, fields));
|
|
232
|
+
}
|
|
233
|
+
function visibility(kind, now, includeFuture, includeExpired) {
|
|
234
|
+
const durable = kind === 'evidence' ? ` OR ${retainedEvidence}` : '';
|
|
235
|
+
const sql = includeExpired ? '1' : `(effective_at>=?${durable})`;
|
|
236
|
+
const args = includeExpired ? [] : [now - LEDGER_RETENTION];
|
|
237
|
+
if (!includeFuture)
|
|
238
|
+
args.push(now);
|
|
239
|
+
return { sql: `${sql}${includeFuture ? '' : ' AND effective_at<=?'}`, args };
|
|
240
|
+
}
|
|
241
|
+
/** Transaction-local storage rows; sequence coordinates never cross HostLedgerPort. */
|
|
242
|
+
export class LedgerTransaction {
|
|
243
|
+
db;
|
|
244
|
+
changed = new Set();
|
|
245
|
+
constructor(db) {
|
|
246
|
+
this.db = db;
|
|
247
|
+
}
|
|
248
|
+
presence(input, now, includeFuture = false) {
|
|
249
|
+
return this.select('presence', input, now, includeFuture);
|
|
250
|
+
}
|
|
251
|
+
evidence(input, now, includeFuture = false) {
|
|
252
|
+
return this.select('evidence', input, now, includeFuture);
|
|
253
|
+
}
|
|
254
|
+
select(kind, input, now, includeFuture) {
|
|
255
|
+
const visible = visibility(kind, now, includeFuture, kind === 'evidence' && input.includeExpired === true), where = [visible.sql], args = visible.args;
|
|
256
|
+
for (const [field, column] of Object.entries({ location: 'location_key', participant: 'participant_key', session: 'session_key', ...(kind === 'evidence' ? { activity: 'activity_key', kind: 'kind', claimToken: 'claim_token_key' } : { channel: 'channel_key' }) })) {
|
|
257
|
+
const value = input[field];
|
|
258
|
+
if (value !== undefined) {
|
|
259
|
+
where.push(`${column}=?`);
|
|
260
|
+
args.push(column === 'kind' ? value : key(field === 'participant' ? nameKey(value) : value));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (kind === 'evidence' && input.includeReleased !== true)
|
|
264
|
+
where.push("outcome<>'released'");
|
|
265
|
+
return this.db.prepare(`SELECT seq,row_json FROM ${tables[kind]} WHERE ${where.join(' AND ')} ORDER BY seq ASC`).all(...args).map((stored) => ({ seq: stored.seq, record: JSON.parse(stored.row_json) }));
|
|
266
|
+
}
|
|
267
|
+
/** Both halves of recognition are read in the enclosing SQLite snapshot, with indexed session/token predicates. */
|
|
268
|
+
nativeTokens(input) {
|
|
269
|
+
const tokens = [...new Set(input.tokens)], encoded = JSON.stringify(tokens.map(key));
|
|
270
|
+
const deliveries = this.db.prepare("SELECT row_json FROM host_evidence WHERE session_key=? AND claim_token_key IN (SELECT value FROM json_each(?)) AND kind='wake' AND json_extract(row_json,'$.nativeDelivery.harness')='claude' ORDER BY seq ASC")
|
|
271
|
+
.all(key(input.session), encoded).map(row => JSON.parse(row.row_json));
|
|
272
|
+
const retired = new Set(this.db.prepare('SELECT token_key FROM host_native_tokens WHERE harness=? AND session_key=? AND token_key IN (SELECT value FROM json_each(?))')
|
|
273
|
+
.all(input.harness, key(input.session), encoded).map(row => row.token_key));
|
|
274
|
+
return { deliveries, retired: tokens.filter(token => retired.has(key(token))) };
|
|
275
|
+
}
|
|
276
|
+
/** Called only after exact-record CAS. No housekeeping of unrelated rows belongs here. */
|
|
277
|
+
retireEvidence(row) {
|
|
278
|
+
const record = row.record;
|
|
279
|
+
if (record.outcome === 'dispatching')
|
|
280
|
+
return false;
|
|
281
|
+
if (record.nativeDelivery?.harness === 'claude') {
|
|
282
|
+
const native = record.nativeDelivery;
|
|
283
|
+
if (!record.session || !validToken(record.claimToken) || typeof native.payload !== 'string' || !native.payload.length
|
|
284
|
+
|| typeof native.endpoint !== 'string' || !native.endpoint.length || !Number.isSafeInteger(native.epoch) || native.epoch < 0)
|
|
285
|
+
return false;
|
|
286
|
+
this.db.prepare("INSERT OR IGNORE INTO host_native_tokens(harness,session_key,token_key) VALUES('claude',?,?)")
|
|
287
|
+
.run(key(record.session), key(record.claimToken));
|
|
288
|
+
}
|
|
289
|
+
this.remove('evidence', [row]);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
insert(kind, row) { insertLedgerRecord(this.db, kind, row); this.changed.add(kind); }
|
|
293
|
+
replace(kind, seq, row) {
|
|
294
|
+
const derived = ledgerRecordColumns(kind, row);
|
|
295
|
+
const result = this.db.prepare(`UPDATE ${tables[kind]} SET ${columns[kind].map((column) => `${column}=?`).join(',')},row_json=? WHERE seq=?`).run(...columns[kind].map((column) => derived[column]), JSON.stringify(row), seq);
|
|
296
|
+
if (result.changes)
|
|
297
|
+
this.changed.add(kind);
|
|
298
|
+
}
|
|
299
|
+
remove(kind, rows) {
|
|
300
|
+
const statement = this.db.prepare(`DELETE FROM ${tables[kind]} WHERE seq=?`);
|
|
301
|
+
for (const row of rows)
|
|
302
|
+
if (statement.run(row.seq).changes)
|
|
303
|
+
this.changed.add(kind);
|
|
304
|
+
}
|
|
305
|
+
/** Only invoke at the point where the former implementation published a mutation. */
|
|
306
|
+
publish(kind, now) {
|
|
307
|
+
const durable = kind === 'evidence' ? ` AND NOT ${retainedEvidence}` : '';
|
|
308
|
+
if (this.db.prepare(`DELETE FROM ${tables[kind]} WHERE effective_at<?${durable}`).run(now - LEDGER_RETENTION).changes)
|
|
309
|
+
this.changed.add(kind);
|
|
310
|
+
}
|
|
311
|
+
commitRevisions() {
|
|
312
|
+
for (const kind of this.changed)
|
|
313
|
+
this.db.exec(`UPDATE host_meta SET ${kind}_revision=${kind}_revision+1 WHERE id=1`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
export async function withLedger(root, write, select, absent, signal) {
|
|
317
|
+
const db = await openLedger(root, write, signal);
|
|
318
|
+
if (!db)
|
|
319
|
+
return absent;
|
|
320
|
+
try {
|
|
321
|
+
return await inSqliteTransaction(db, write, () => validateLedgerHeader(db), () => {
|
|
322
|
+
const rows = new LedgerTransaction(db), result = select(rows);
|
|
323
|
+
rows.commitRevisions();
|
|
324
|
+
return result;
|
|
325
|
+
}, signal);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
db.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export async function readLedgerRevisions(root, signal) {
|
|
332
|
+
const db = await openLedger(root, false, signal);
|
|
333
|
+
if (!db)
|
|
334
|
+
return undefined;
|
|
335
|
+
try {
|
|
336
|
+
return await inSqliteTransaction(db, false, () => validateLedgerHeader(db), () => ledgerRevisions(db), signal);
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
db.close();
|
|
340
|
+
}
|
|
341
|
+
}
|
package/dist/host-ledger.d.ts
CHANGED
|
@@ -91,6 +91,7 @@ export interface EvidenceRelease {
|
|
|
91
91
|
readonly diagnostic?: unknown;
|
|
92
92
|
readonly now?: number;
|
|
93
93
|
}
|
|
94
|
+
/** includeExpired is maintenance-only: bypass age retention, never future filtering. */
|
|
94
95
|
export interface EvidenceLookup {
|
|
95
96
|
readonly location?: string;
|
|
96
97
|
readonly participant?: string;
|
|
@@ -99,10 +100,17 @@ export interface EvidenceLookup {
|
|
|
99
100
|
readonly kind?: EvidenceRecord['kind'];
|
|
100
101
|
readonly now?: number;
|
|
101
102
|
readonly includeReleased?: boolean;
|
|
103
|
+
readonly includeExpired?: boolean;
|
|
102
104
|
}
|
|
103
|
-
export interface
|
|
104
|
-
readonly
|
|
105
|
-
readonly
|
|
105
|
+
export interface NativeTokenLookup {
|
|
106
|
+
readonly harness: 'claude';
|
|
107
|
+
readonly session: string;
|
|
108
|
+
readonly tokens: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
/** One snapshot of live correlations and recognition-only retired tokens. Neither tokens nor lookup confer authority. */
|
|
111
|
+
export interface NativeTokenResult {
|
|
112
|
+
readonly deliveries: readonly EvidenceRecord[];
|
|
113
|
+
readonly retired: readonly string[];
|
|
106
114
|
}
|
|
107
115
|
export interface WakeAttention {
|
|
108
116
|
readonly squarePath: string;
|
|
@@ -160,6 +168,8 @@ export interface HostLedgerPort {
|
|
|
160
168
|
readonly claimToken: string;
|
|
161
169
|
}): Promise<void>;
|
|
162
170
|
listEvidence(input: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
|
|
171
|
+
retireEvidenceIfUnchanged(expected: EvidenceRecord): Promise<boolean>;
|
|
172
|
+
lookupNativeTokens(input: NativeTokenLookup): Promise<NativeTokenResult>;
|
|
163
173
|
listWakeAttempts(input?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
|
|
164
174
|
prepareNativeWake(input: EvidenceRecord & {
|
|
165
175
|
readonly claimToken: string;
|
|
@@ -170,5 +180,4 @@ export interface HostLedgerPort {
|
|
|
170
180
|
readonly claimToken: string;
|
|
171
181
|
}): Promise<boolean>;
|
|
172
182
|
claimWakeAttempt(input: WakeAttemptClaimInput): Promise<WakeAttemptClaim>;
|
|
173
|
-
gcEvidence(input: EvidenceGc): Promise<void>;
|
|
174
183
|
}
|