@astrosheep/square 0.3.42 → 0.3.43

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/file-lock.js CHANGED
@@ -1,95 +1,25 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { randomUUID } from 'node:crypto';
3
2
  import { promises as fs } from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { setTimeout as sleep } from 'node:timers/promises';
6
- function isBusy(error) {
7
- return error instanceof Error && /database is locked|SQLITE_BUSY/i.test(error.message);
8
- }
9
- async function ownerState(lockPath) {
10
- let pid;
11
- try {
12
- pid = Number.parseInt((await fs.readFile(lockPath, 'utf8')).split('\n')[0], 10);
13
- }
14
- catch {
15
- return 'unknown';
16
- }
17
- if (!Number.isSafeInteger(pid) || pid <= 0)
18
- return 'unknown';
19
- try {
20
- process.kill(pid, 0);
21
- return 'alive';
22
- }
23
- catch (error) {
24
- return error.code === 'ESRCH' ? 'dead' : 'alive';
25
- }
26
- }
27
- async function createLegacyLock(lockPath) {
28
- let fd;
29
- try {
30
- fd = await fs.open(lockPath, 'wx', 0o600);
31
- }
32
- catch (error) {
33
- if (error.code === 'EEXIST')
34
- return undefined;
35
- throw error;
36
- }
37
- const token = `${process.pid}\n${Date.now()}\n${randomUUID()}\n`;
38
- try {
39
- await fd.writeFile(token, 'utf8');
40
- return token;
41
- }
42
- catch (error) {
43
- await fs.unlink(lockPath).catch(() => undefined);
44
- throw error;
45
- }
46
- finally {
47
- await fd.close();
5
+ export class FileLockError extends Error {
6
+ code = 'not_a_database';
7
+ constructor(lockPath, cause) {
8
+ super(`File lock is not a database: ${lockPath}`);
9
+ this.name = 'FileLockError';
10
+ if (cause !== undefined)
11
+ this.cause = cause;
48
12
  }
49
13
  }
50
- async function reclaimLegacyLock(lockPath, staleMs) {
51
- const quarantinePath = `${lockPath}.reclaim-${randomUUID()}`;
52
- try {
53
- const stale = Date.now() - (await fs.stat(lockPath)).mtimeMs > staleMs;
54
- const owner = await ownerState(lockPath);
55
- if (owner === 'alive' || (owner !== 'dead' && !stale))
56
- return false;
57
- await fs.rename(lockPath, quarantinePath);
58
- await fs.unlink(quarantinePath).catch(() => undefined);
59
- return true;
60
- }
61
- catch (error) {
62
- await fs.unlink(quarantinePath).catch(() => undefined);
63
- return error.code === 'ENOENT';
64
- }
14
+ function isBusy(error) {
15
+ return error instanceof Error && /database is locked|SQLITE_BUSY/i.test(error.message);
65
16
  }
66
- async function releaseLegacyLock(lockPath, token) {
67
- try {
68
- if (await fs.readFile(lockPath, 'utf8') === token)
69
- await fs.unlink(lockPath);
70
- }
71
- catch { }
17
+ function isNotADatabase(error) {
18
+ return error instanceof Error && /not a database|file is not a database/i.test(error.message);
72
19
  }
73
- async function withLegacyFileLock(lockPath, options, fn) {
74
- let token;
75
- while (token === undefined) {
76
- if (options.signal?.aborted)
77
- throw options.signal.reason ?? new Error('File lock acquisition aborted');
78
- token = await createLegacyLock(lockPath);
79
- if (token !== undefined)
80
- break;
81
- if (await reclaimLegacyLock(lockPath, options.staleMs))
82
- continue;
83
- await sleep(options.retryMs, undefined, { signal: options.signal });
84
- }
85
- try {
86
- return await fn();
87
- }
88
- finally {
89
- await releaseLegacyLock(lockPath, token);
90
- }
91
- }
92
- async function withSqliteLock(lockPath, options, fn) {
20
+ export async function withFileLock(lockPath, options, fn) {
21
+ if (options.createParent !== false)
22
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
93
23
  while (true) {
94
24
  if (options.signal?.aborted)
95
25
  throw options.signal.reason ?? new Error('File lock acquisition aborted');
@@ -109,9 +39,8 @@ async function withSqliteLock(lockPath, options, fn) {
109
39
  await sleep(options.retryMs, undefined, { signal: options.signal });
110
40
  continue;
111
41
  }
112
- if (error instanceof Error && /not a database|file is not a database/i.test(error.message)) {
113
- return withLegacyFileLock(lockPath, options, fn);
114
- }
42
+ if (isNotADatabase(error))
43
+ throw new FileLockError(lockPath, error);
115
44
  throw error;
116
45
  }
117
46
  try {
@@ -131,7 +60,3 @@ async function withSqliteLock(lockPath, options, fn) {
131
60
  }
132
61
  }
133
62
  }
134
- export async function withFileLock(lockPath, options, fn) {
135
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
136
- return withSqliteLock(lockPath, options, fn);
137
- }
@@ -24,10 +24,14 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
24
24
  transitionWakeDispatch(i: WakeDispatchTransitionInput): Promise<boolean>;
25
25
  releaseWakeDispatch(i: WakeDispatchReleaseInput): Promise<void>;
26
26
  listWakeAttempts(i?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
27
- appendWakeAttempt(i: EvidenceRecord): Promise<void>;
27
+ appendWakeAttempt(i: EvidenceRecord & {
28
+ readonly claimToken: string;
29
+ }): Promise<void>;
28
30
  claimEvidence(i: EvidenceClaim): Promise<ClaimResult>;
29
31
  releaseEvidence(i: EvidenceRelease): Promise<void>;
30
- appendEvidence(i: EvidenceRecord): Promise<void>;
32
+ appendEvidence(i: EvidenceRecord & {
33
+ readonly claimToken: string;
34
+ }): Promise<void>;
31
35
  listEvidence(i?: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
32
36
  gcEvidence(i: EvidenceGc): Promise<void>;
33
37
  reconcileBinding(i?: ReconcileBindingInput): Promise<ReconcileBindingResult>;
@@ -1,10 +1,11 @@
1
1
  import { promises as fs } from 'node:fs';
2
+ import fsSync from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { withFileLock } from './file-lock.js';
5
6
  import { nameKey } from './model.js';
6
7
  import { isCurrentlyJoined } from './runtime.js';
7
- const LOCK = { retryMs: 10, staleMs: 300000 };
8
+ const LOCK = { retryMs: 10 };
8
9
  const RETENTION = 7 * 86400000;
9
10
  async function canon(value) { const absolute = path.resolve(value); try {
10
11
  return await fs.realpath(absolute);
@@ -12,11 +13,26 @@ async function canon(value) { const absolute = path.resolve(value); try {
12
13
  catch {
13
14
  return absolute;
14
15
  } }
16
+ function canonRoot(value) { let current = path.resolve(value), suffix = []; for (;;) {
17
+ try {
18
+ return path.join(fsSync.realpathSync.native(current), ...suffix.reverse());
19
+ }
20
+ catch (error) {
21
+ if (error.code !== 'ENOENT')
22
+ return path.resolve(value);
23
+ const parent = path.dirname(current);
24
+ if (parent === current)
25
+ return path.resolve(value);
26
+ suffix.push(path.basename(current));
27
+ current = parent;
28
+ }
29
+ } }
15
30
  async function read(file, now, includeFuture = false) { try {
16
31
  return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
17
32
  const row = JSON.parse(line);
18
33
  const at = row.at ?? row.updatedAt;
19
- return row.v === 1 && typeof at === 'number' && at >= now - RETENTION && (includeFuture || at <= now) ? [row] : [];
34
+ const durableTerminal = row.kind === 'wake' && row.outcome === 'accepted';
35
+ return row.v === 1 && typeof at === 'number' && (durableTerminal || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
20
36
  }
21
37
  catch {
22
38
  return [];
@@ -28,7 +44,7 @@ catch (e) {
28
44
  throw e;
29
45
  } }
30
46
  async function write(file, rows) { await fs.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(tmp, rows.length ? rows.map(row => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600 }); await fs.rename(tmp, file); }
31
- function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
47
+ function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind, v.scope ?? null]); }
32
48
  function evidenceKey(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.activity, v.kind, v.attemptN ?? null]); }
33
49
  async function readClaims(file, now) { try {
34
50
  return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
@@ -60,12 +76,12 @@ export class FileHostLedgerPort {
60
76
  readable;
61
77
  claims;
62
78
  clock;
63
- constructor(o = {}) { const testRoot = process.env.SQUARE_REGISTRY ? path.dirname(process.env.SQUARE_REGISTRY) : undefined; this.user = o.userPath ?? (testRoot ?? path.join(os.homedir(), '.square', 'host-ledger')); this.local = o.localPath ?? (testRoot ?? path.join(process.cwd(), '.square', 'host-ledger')); this.writable = o.writableScope ?? 'local'; this.readable = o.readableScopes ?? ['user', 'local']; this.claims = o.claimsPath ?? path.join(this.user, 'wake-claims.ndjsonl'); this.clock = o.now ?? Date.now; }
79
+ constructor(o = {}) { const testRoot = process.env.SQUARE_REGISTRY ? path.dirname(process.env.SQUARE_REGISTRY) : undefined; this.user = canonRoot(o.userPath ?? (testRoot ?? path.join(os.homedir(), '.square', 'host-ledger'))); this.local = canonRoot(o.localPath ?? (testRoot ?? path.join(process.cwd(), '.square', 'host-ledger'))); this.writable = o.writableScope ?? 'local'; this.readable = o.readableScopes ?? ['user', 'local']; this.claims = canonRoot(o.claimsPath ?? path.join(this.user, 'wake-claims.ndjsonl')); this.clock = o.now ?? Date.now; }
64
80
  file(s, f) { return path.join(s === 'user' ? this.user : this.local, `${f}.ndjsonl`); }
65
- async claimPresence(i, scope = this.writable) { const location = await canon(i.location), r = { ...i, location, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }, lock = path.join(this.user, 'presence-claim.lock'); try {
81
+ async claimPresence(i, scope = this.writable) { const location = await canon(i.location), r = { ...i, location, scope, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }, lock = path.join(this.user, 'presence-claim.lock'); try {
66
82
  return await withFileLock(lock, LOCK, async () => { const candidates = []; for (const s of ['user', 'local']) {
67
83
  for (const row of await read(this.file(s, 'presence'), this.clock(), true)) {
68
- if (row.location !== location || nameKey(row.participant) !== nameKey(r.participant))
84
+ if ((row.scope ?? s) !== s || row.location !== location || nameKey(row.participant) !== nameKey(r.participant))
69
85
  continue;
70
86
  candidates.push(s === 'user' ? row : { ...row, route: undefined });
71
87
  }
@@ -73,37 +89,37 @@ export class FileHostLedgerPort {
73
89
  if (existing.session === r.session)
74
90
  return { status: 'owned', record: existing };
75
91
  return { status: 'busy', record: existing };
76
- } const f = this.file(scope, 'presence'); await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k(x) !== k(r)), r])); return { status: 'acquired', record: r }; });
92
+ } const f = this.file(scope, 'presence'); await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k({ ...x, scope: x.scope ?? scope }) !== k(r)), r])); return { status: 'acquired', record: r }; });
77
93
  }
78
94
  catch (error) {
79
95
  return { status: 'degraded', record: r, error };
80
96
  } }
81
- async ensurePresence(i, scope = this.writable) { const r = { ...i, location: await canon(i.location), ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }; const f = this.file(scope, 'presence'); try {
82
- await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k(x) !== k(r)), r]));
97
+ async ensurePresence(i, scope = this.writable) { const r = { ...i, location: await canon(i.location), scope, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }; const f = this.file(scope, 'presence'); try {
98
+ await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k({ ...x, scope: x.scope ?? scope }) !== k(r)), r]));
83
99
  return { status: 'ensured', record: r };
84
100
  }
85
101
  catch (error) {
86
102
  return { status: 'degraded', record: r, error };
87
103
  } }
88
- async removePresence(i) { const r = { ...i, location: await canon(i.location) }, f = this.file(this.writable, 'presence'); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(x => k(x) !== k(r)))); }
104
+ async removePresence(i) { const r = { ...i, location: await canon(i.location), scope: this.writable }, f = this.file(this.writable, 'presence'), shared = this.user === this.local; await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(x => { const rowScope = x.scope ?? this.writable; return shared ? k({ ...x, scope: rowScope }) !== k({ ...r, scope: rowScope }) : k({ ...x, scope: rowScope }) !== k(r); }))); }
89
105
  async listPresence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location), m = new Map(); for (const s of i.scopes ?? this.readable)
90
106
  for (const r of await read(this.file(s, 'presence'), i.now ?? this.clock())) {
91
- if (loc && r.location !== loc || i.participant && nameKey(r.participant) !== nameKey(i.participant) || i.session && r.session !== i.session)
107
+ if ((r.scope ?? s) !== s || loc && r.location !== loc || i.participant && nameKey(r.participant) !== nameKey(i.participant) || i.session && r.session !== i.session)
92
108
  continue;
93
- const x = s === 'user' ? r : { ...r, route: undefined }, key = k(x), old = m.get(key);
109
+ const x = s === 'user' ? r : { ...r, route: undefined }, key = k({ ...x, scope: undefined }), old = m.get(key);
94
110
  if (old?.scope === 'user' && s === 'local')
95
111
  continue;
96
112
  if (!old || s === 'user' || x.updatedAt >= old.record.updatedAt)
97
113
  m.set(key, { record: x, scope: s });
98
- } return [...m.values()].map(({ record }) => record); }
99
- async claimWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, i.session ?? null, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), existing = rows.find((row) => row.attention_key === key); if (existing?.phase === 'dispatching') {
114
+ } return [...m.values()].map(({ record }) => { const { scope: _scope, ...publicRecord } = record; return publicRecord; }); }
115
+ async claimWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), existing = rows.find((row) => row.attention_key === key); if (existing?.phase === 'dispatching') {
100
116
  const alive = processAlive(existing.ownerPid);
101
117
  if (existing.expiresAt <= at || alive === false)
102
118
  return { type: 'ambiguous', lease: existing };
103
119
  return { type: 'busy' };
104
120
  } if (existing !== undefined && existing.expiresAt > at)
105
121
  return { type: 'busy' }; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: 'claimed', ownerPid: process.pid, ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row.attention_key !== key), next]); return { type: 'acquired', leaseId: i.leaseId }; }); }
106
- async transitionWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, i.session ?? null, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), current = rows.find((row) => { if (row.leaseId !== i.leaseId)
122
+ async transitionWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), current = rows.find((row) => { if (row.leaseId !== i.leaseId)
107
123
  return false; try {
108
124
  const parts = JSON.parse(row.attention_key);
109
125
  return parts[0] === location && parts[1] === nameKey(i.attention.recipient) && parts[2] === i.attention.actIndex;
@@ -112,22 +128,25 @@ export class FileHostLedgerPort {
112
128
  return false;
113
129
  } }); if (current === undefined)
114
130
  return false; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: i.phase, ownerPid: current.ownerPid, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row !== current), next]); return true; }); }
115
- async releaseWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, i.session ?? null, 'wake']), at = i.at ?? this.clock(); await withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at); await writeClaims(this.claims, rows.filter((row) => row.attention_key !== key || row.leaseId !== i.leaseId)); }); }
131
+ async releaseWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); await withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at); await writeClaims(this.claims, rows.filter((row) => row.attention_key !== key || row.leaseId !== i.leaseId)); }); }
116
132
  async listWakeAttempts(i = {}) { const attention = i.attention; return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canon(attention.squarePath), participant: attention.recipient, activity: `act/${attention.actIndex}` }), ...(i.session === undefined ? {} : { session: i.session }), now: i.now }); }
117
133
  async appendWakeAttempt(i) { if (i.kind !== 'wake')
118
- throw new Error('Wake attempt rows require wake evidence kind'); return this.appendEvidence(i); }
119
- async claimEvidence(i) { const f = this.file('user', 'evidence'), at = i.now ?? this.clock(), { leaseMs, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
134
+ throw new Error('Wake attempt rows require wake evidence kind'); await this.appendEvidence(i); }
135
+ async claimEvidence(i) { const f = this.file('user', 'evidence'), at = i.now ?? this.clock(), { leaseMs, claimToken: requestedToken, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
120
136
  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)
121
137
  return { status: 'busy', record: old }; const delivered = (r.kind === 'wake' && matching.some(x => x.outcome === 'accepted')) || (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented')); if (delivered)
122
- return { status: 'delivered', record: matching.findLast(x => x.outcome === 'accepted' || x.outcome === 'presented') }; 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')), r]); return { status: 'acquired' }; });
138
+ return { status: 'delivered', record: matching.findLast(x => x.outcome === 'accepted' || x.outcome === 'presented') }; const index = Number(r.activity.slice(4)), attentionKey = JSON.stringify([r.location, nameKey(r.participant), index, r.session ?? null, 'wake']), dispatchToken = r.kind === 'wake' && typeof requestedToken === 'string' && Number.isInteger(index) && (await readClaims(this.claims, at)).some((row) => row.leaseId === requestedToken && row.attention_key === attentionKey) ? requestedToken : undefined, claimToken = dispatchToken ?? `${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 }; });
123
139
  }
124
140
  catch (error) {
125
141
  return { status: 'degraded', error };
126
142
  } }
127
- async releaseEvidence(i) { const f = this.file('user', 'evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, at, true)).filter(x => !(x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session && x.outcome === 'dispatching')))); }
128
- async appendEvidence(i) { const f = this.file('user', 'evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); const base = all.filter(x => k(x) === k(r)); const replaceable = new Set(base.filter(x => x.outcome === 'dispatching' || (r.attemptN !== undefined && x.attemptN === r.attemptN)).map(x => evidenceKey(x))); await write(f, [...all.filter(x => !(x.outcome === 'dispatching' && k(x) === k(r)) && !replaceable.has(evidenceKey(x))), r]); }); }
129
- async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('user', 'evidence'), i.now ?? this.clock())).filter(r => (!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)); }
130
- async gcEvidence(i) { const f = this.file('user', 'evidence'); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock())).filter(r => r.at >= i.before))); }
143
+ async releaseEvidence(i) { const f = this.file('user', 'evidence'), location = await canon(i.location), at = i.now ?? this.clock(); 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)
144
+ return; const tombstone = { ...current, outcome: 'released', at, expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
145
+ async appendEvidence(i) { const f = this.file('user', 'evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; const token = i.claimToken; if (typeof token !== 'string' || token.length === 0)
146
+ return; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); 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; const current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== token)
147
+ return; const terminalKey = (x) => same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released'; await write(f, [...all.filter(x => x !== current && !terminalKey(x)), r]); }); }
148
+ async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('user', 'evidence'), i.now ?? this.clock())).filter(r => 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)); }
149
+ async gcEvidence(i) { const f = this.file('user', '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 || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
131
150
  async reconcileBinding(i = {}) { let b = []; try {
132
151
  b = [...await this.listPresence({ scopes: i.scopes, now: i.now })];
133
152
  if (i.artifact) {
@@ -53,6 +53,7 @@ export interface EvidenceRecord {
53
53
  readonly attemptN?: number;
54
54
  readonly message?: string;
55
55
  readonly diagnostic?: unknown;
56
+ readonly claimToken?: string;
56
57
  }
57
58
  export interface EvidenceClaim {
58
59
  readonly location: string;
@@ -61,9 +62,12 @@ export interface EvidenceClaim {
61
62
  readonly activity: string;
62
63
  readonly kind: EvidenceRecord['kind'];
63
64
  readonly leaseMs: number;
65
+ readonly claimToken?: string;
64
66
  readonly now?: number;
65
67
  }
66
- export type EvidenceRelease = Omit<EvidenceClaim, 'leaseMs'>;
68
+ export type EvidenceRelease = Omit<EvidenceClaim, 'leaseMs'> & {
69
+ readonly claimToken: string;
70
+ };
67
71
  export interface EvidenceLookup {
68
72
  readonly location?: string;
69
73
  readonly participant?: string;
@@ -74,6 +78,7 @@ export interface EvidenceLookup {
74
78
  }
75
79
  export interface EvidenceGc {
76
80
  readonly before: number;
81
+ readonly pendingWakeActivities?: readonly string[];
77
82
  }
78
83
  export interface WakeAttention {
79
84
  readonly squarePath: string;
@@ -127,6 +132,7 @@ export interface WakeAttemptLookup {
127
132
  }
128
133
  export type ClaimResult = {
129
134
  readonly status: 'acquired';
135
+ readonly claimToken: string;
130
136
  } | {
131
137
  readonly status: 'busy' | 'delivered';
132
138
  readonly record: EvidenceRecord;
@@ -160,10 +166,14 @@ export interface HostLedgerPort {
160
166
  listPresence(input: PresenceLookup): Promise<readonly PresenceRecord[]>;
161
167
  claimEvidence(input: EvidenceClaim): Promise<ClaimResult>;
162
168
  releaseEvidence(input: EvidenceRelease): Promise<void>;
163
- appendEvidence(input: EvidenceRecord): Promise<void>;
169
+ appendEvidence(input: EvidenceRecord & {
170
+ readonly claimToken: string;
171
+ }): Promise<void>;
164
172
  listEvidence(input: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
165
173
  listWakeAttempts(input?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
166
- appendWakeAttempt(input: EvidenceRecord): Promise<void>;
174
+ appendWakeAttempt(input: EvidenceRecord & {
175
+ readonly claimToken: string;
176
+ }): Promise<void>;
167
177
  claimWakeDispatch(input: WakeDispatchClaimInput): Promise<WakeDispatchClaim>;
168
178
  transitionWakeDispatch(input: WakeDispatchTransitionInput): Promise<boolean>;
169
179
  releaseWakeDispatch(input: WakeDispatchReleaseInput): Promise<void>;
@@ -9,7 +9,8 @@ import { displayAttentionPath } from './attention-presentation.js';
9
9
  import { openSquare } from './square-file-adapter.js';
10
10
  import { closeOpenSquare } from './open-square.js';
11
11
  import { notificationDelivered, resolveParticipant } from './views.js';
12
- import { deliverPending, sweepPending, sweepPendingFromState } from './delivery-operations.js';
12
+ import { deliverPending, observeSquare, sweepPending, sweepPendingFromState } from './delivery-operations.js';
13
+ import { nameKey } from './model.js';
13
14
  import { projectPresentationEvidence } from './square-projections.js';
14
15
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
15
16
  export { planActNotifications, matchesMentionTarget };
@@ -101,6 +102,34 @@ export async function createDefaultWakeTransport(hostLedger, clock, env = proces
101
102
  const adapters = await defaultWakeAdapters();
102
103
  return createWakeTransport(env.SQUARE_DISABLE_PASEO_WAKE === '1' ? adapters.filter((adapter) => adapter.kind !== 'paseo') : adapters, hostLedger, clock);
103
104
  }
105
+ async function wakeRequestIsCurrent(request, hostLedger, now) {
106
+ const activity = parseActivityId(request.activity);
107
+ if (activity === undefined)
108
+ return false;
109
+ let square;
110
+ try {
111
+ square = await openSquare(request.location, { hostLedger });
112
+ const current = await observeSquare({ artifact: square.artifact, hostLedger, location: request.location, now });
113
+ const pending = current.pending.some((entry) => nameKey(entry.recipient) === nameKey(request.participant)
114
+ && entry.notifications.some((notification) => notification.item.index === activity));
115
+ const bound = current.bindings.some((binding) => nameKey(binding.participant) === nameKey(request.route.participant)
116
+ && binding.sessionId === request.route.sessionId
117
+ && binding.location === request.route.location);
118
+ const published = (current.state.routes ?? []).some((route) => route.location === request.route.location
119
+ && nameKey(route.participant) === nameKey(request.route.participant)
120
+ && route.sessionId === request.route.sessionId
121
+ && route.kind === request.route.kind
122
+ && JSON.stringify(route.address) === JSON.stringify(request.route.address));
123
+ return pending && bound && published;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ finally {
129
+ if (square !== undefined)
130
+ await closeOpenSquare(square);
131
+ }
132
+ }
104
133
  export function createWakeTransport(adapters, hostLedger, clock) {
105
134
  return {
106
135
  probe: async (route) => {
@@ -117,12 +146,12 @@ export function createWakeTransport(adapters, hostLedger, clock) {
117
146
  return { outcome: 'not-capable', diagnostic: error instanceof Error ? error.message : String(error) };
118
147
  }
119
148
  },
120
- attempt: async (request, timeoutMs) => {
149
+ attempt: async (request, timeoutMs, beforeSend) => {
121
150
  const adapter = adapters.find((candidate) => candidate.kind === request.route.kind);
122
151
  if (adapter === undefined)
123
152
  return { outcome: 'not-capable', diagnostic: `no adapter for ${request.route.kind}` };
124
153
  try {
125
- const result = await adapter.dispatch(request.route.address, renderWakePayload(request), async () => true, timeoutMs);
154
+ const result = await adapter.dispatch(request.route.address, renderWakePayload(request), async () => (await (beforeSend ?? (async () => true))()) && await wakeRequestIsCurrent(request, hostLedger, clock()), timeoutMs);
126
155
  if (result.outcome === 'accepted')
127
156
  return { outcome: 'accepted' };
128
157
  if (result.outcome === 'failed')
@@ -7,7 +7,7 @@ const nativeSessions = [
7
7
  { variable: 'CLAUDE_CODE_SESSION_ID', provider: 'claude' },
8
8
  { variable: 'CODEX_THREAD_ID', provider: 'codex' },
9
9
  { variable: 'OPENCODE_SESSION_ID', provider: 'opencode' },
10
- { variable: 'PI_SESSION_ID', provider: 'pi' },
10
+ { variable: 'SQUARE_PI_SESSION_ID', provider: 'pi' },
11
11
  ];
12
12
  export function automaticParticipant(provider, sessionId, env) {
13
13
  const configured = env.SQUARE_PARTICIPANT_NAME?.trim();
package/dist/ports.d.ts CHANGED
@@ -11,7 +11,10 @@ export interface SquareArtifactPort {
11
11
  transact<R>(fn: (state: SquareState, version: number) => {
12
12
  state?: SquareState;
13
13
  result: R;
14
- }): Promise<R>;
14
+ } | Promise<{
15
+ state?: SquareState;
16
+ result: R;
17
+ }>): Promise<R>;
15
18
  changed(sinceVersion: number, timeoutMs: number): Promise<boolean>;
16
19
  close(): Promise<void>;
17
20
  }
@@ -22,7 +25,7 @@ export interface WakeTransportPort {
22
25
  readonly outcome: 'not-capable';
23
26
  readonly diagnostic?: string;
24
27
  }>;
25
- attempt(request: WakeRequest, timeoutMs: number): Promise<WakeOutcome>;
28
+ attempt(request: WakeRequest, timeoutMs: number, beforeSend?: () => Promise<boolean>): Promise<WakeOutcome>;
26
29
  /** Optional route retirement supplied by the concrete executor adapter. */
27
30
  invalidate?(request: WakeRequest): Promise<void>;
28
31
  }
@@ -17,27 +17,29 @@ export async function presentPending(input) {
17
17
  const item = before.state.acts.find((activity) => activity.index === index);
18
18
  if (item === undefined || delivery.isSeen(input.participant, index) || !delivery.pendingFor(input.participant).some((notification) => notification.item.index === index))
19
19
  return { presented: false };
20
+ let claimToken;
20
21
  if (input.hostLedger !== undefined && input.session !== undefined) {
21
- const claim = await input.hostLedger.claimEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', leaseMs: input.timeoutMs ?? 5000, now: input.now });
22
+ const claim = await input.hostLedger.claimEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', leaseMs: input.timeoutMs ?? 5000 });
22
23
  if (claim.status !== 'acquired')
23
24
  return { presented: false };
25
+ claimToken = claim.claimToken;
24
26
  let current;
25
27
  try {
26
28
  current = await input.artifact.read();
27
29
  }
28
30
  catch {
29
- await input.hostLedger.releaseEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', now: input.now }).catch(() => undefined);
31
+ await input.hostLedger.releaseEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', claimToken }).catch(() => undefined);
30
32
  return { presented: false };
31
33
  }
32
34
  if (deriveDeliveryModel(current.state).isSeen(input.participant, index)) {
33
- await input.hostLedger.releaseEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', now: input.now }).catch(() => undefined);
35
+ await input.hostLedger.releaseEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', claimToken }).catch(() => undefined);
34
36
  return { presented: false };
35
37
  }
36
38
  try {
37
39
  await input.sink.present(item);
38
40
  }
39
41
  catch (error) {
40
- await input.hostLedger.appendEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', outcome: 'failed', message: error instanceof Error ? error.message : String(error), at: input.now });
42
+ await input.hostLedger.appendEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', outcome: 'failed', message: error instanceof Error ? error.message : String(error), claimToken });
41
43
  throw error;
42
44
  }
43
45
  }
@@ -45,7 +47,7 @@ export async function presentPending(input) {
45
47
  await input.sink.present(item);
46
48
  if (input.markSeen !== false)
47
49
  await input.artifact.transact((state) => { const changed = recordObservation(state, input.participant, index, 'seen', input.now ?? Date.now()); return changed ? { state, result: undefined } : { result: undefined }; });
48
- if (input.hostLedger !== undefined && input.session !== undefined)
49
- await input.hostLedger.appendEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', outcome: input.markSeen === false ? 'clipped' : 'presented', ...(input.markSeen === false ? { message: 'presentation clipped' } : {}), at: input.now });
50
+ if (input.hostLedger !== undefined && input.session !== undefined && claimToken !== undefined)
51
+ await input.hostLedger.appendEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', outcome: input.markSeen === false ? 'clipped' : 'presented', ...(input.markSeen === false ? { message: 'presentation clipped' } : {}), claimToken });
50
52
  return { presented: true, activity: item };
51
53
  }
@@ -10,6 +10,7 @@ export interface RegistryBinding {
10
10
  child: boolean;
11
11
  route?: PresenceRecord['route'];
12
12
  updatedAt: number;
13
+ epoch: number;
13
14
  }
14
15
  export interface RegistryWriteOptions {
15
16
  channel?: SessionChannel;
@@ -18,8 +19,14 @@ export interface RegistryWriteOptions {
18
19
  at?: number;
19
20
  env?: NodeJS.ProcessEnv;
20
21
  }
22
+ type PresenceWithEpoch = PresenceRecord & {
23
+ readonly epoch?: number;
24
+ };
21
25
  export declare function registryPath(env?: NodeJS.ProcessEnv): string;
22
26
  export declare function canonicalSquarePath(squarePath: string): Promise<string>;
27
+ export declare function presenceEpoch(record: PresenceWithEpoch | undefined): number;
28
+ /** Runs `fn` inside the ownership claim critical section, under the same lock as claims and finalize. */
29
+ export declare function withOwnershipClaimLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>): Promise<T>;
23
30
  export declare function recordJoin(sessionId: string, name: string, squarePath: string, options?: RegistryWriteOptions): Promise<void>;
24
31
  export declare function recordDone(sessionId: string, name: string, squarePath: string, options?: RegistryWriteOptions): Promise<void>;
25
32
  export declare function readActiveBindings(now?: number, env?: NodeJS.ProcessEnv): Promise<RegistryBinding[]>;
@@ -36,10 +43,40 @@ export type CurrentParticipantBinding = Readonly<{
36
43
  created: boolean;
37
44
  sessionId: string;
38
45
  }>;
39
- export declare function claimSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<{
46
+ export type OwnershipClaim = {
40
47
  readonly status: 'acquired' | 'owned';
41
48
  readonly sessionId: string;
42
- } | undefined>;
49
+ readonly epoch: number;
50
+ } | undefined;
51
+ export type TakeoverClaimToken = {
52
+ readonly sessionId: string;
53
+ readonly epoch: number;
54
+ };
55
+ export type TakeoverRunResult<T> = {
56
+ readonly status: 'acquired';
57
+ readonly sessionId: string;
58
+ readonly epoch: number;
59
+ readonly result: T;
60
+ } | {
61
+ readonly status: 'busy';
62
+ readonly epoch: number;
63
+ };
64
+ export declare function readParticipantOwner(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<RegistryBinding | undefined>;
65
+ export declare function sessionOwnsParticipant(squarePath: string, name: string, sessionId: string, env?: NodeJS.ProcessEnv, expectedEpoch?: number): Promise<boolean>;
66
+ export declare function claimSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<OwnershipClaim>;
67
+ /**
68
+ * Fenced takeover: the ownership claim and the lifecycle run as one critical section under the
69
+ * presence-claim lock, so no second takeover can interleave between claim and commit. The claim
70
+ * persists nothing until the lifecycle commits: standing owner rows stay authoritative through
71
+ * the lifecycle (a self-takeover can therefore never clobber its own standing row). On success
72
+ * the standing rows are replaced by the new owner's row at the claim epoch; on refusal, rows
73
+ * carrying the claim token (session + channel + claim epoch) are withdrawn and the captured
74
+ * standing rows are restored exactly.
75
+ */
76
+ export declare function claimSessionTakeover<T>(squarePath: string, name: string, env: NodeJS.ProcessEnv | undefined, opts: {
77
+ readonly expectedEpoch?: number;
78
+ readonly expectedSession?: string;
79
+ } | undefined, lifecycle: (claim: TakeoverClaimToken) => Promise<T>): Promise<TakeoverRunResult<T>>;
43
80
  export declare function releaseSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<void>;
44
81
  export declare function bindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<CurrentParticipantBinding>;
45
82
  export declare function unbindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
@@ -60,3 +97,4 @@ export declare function recordLocalJoin(name: string, squarePath: string, env?:
60
97
  export declare function recordLocalDone(name: string, squarePath: string, env?: NodeJS.ProcessEnv): Promise<void>;
61
98
  export declare function recordSessionJoin(sessionId: string, name: string, squarePath: string, channel: SessionChannel, env?: NodeJS.ProcessEnv): Promise<string>;
62
99
  export declare function recordSessionDone(sessionId: string, name: string, squarePath: string, channel: SessionChannel, env?: NodeJS.ProcessEnv): Promise<boolean>;
100
+ export {};