@astrosheep/square 0.3.61 → 0.3.62
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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/skills/square/SKILL.md +5 -7
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/activity.d.ts +6 -2
- package/dist/activity.js +168 -133
- package/dist/artifact.d.ts +4 -0
- package/dist/artifact.js +43 -14
- package/dist/attention-presentation.d.ts +1 -0
- package/dist/attention-presentation.js +8 -6
- package/dist/automatic-session.js +14 -24
- package/dist/boundary-presentation.d.ts +1 -2
- package/dist/boundary-presentation.js +4 -19
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/context.js +2 -18
- package/dist/cli/harness-command.js +1 -1
- package/dist/cli/mcp-server-command.d.ts +2 -0
- package/dist/cli/mcp-server-command.js +13 -0
- package/dist/cli/observation-commands.js +47 -19
- package/dist/cli/program.js +5 -2
- package/dist/cli/registry.js +2 -0
- package/dist/cli/square-commands.d.ts +1 -1
- package/dist/cli/square-commands.js +93 -125
- package/dist/decisions.d.ts +2 -0
- package/dist/decisions.js +44 -3
- package/dist/delivery-operations.d.ts +1 -2
- package/dist/delivery-operations.js +1 -4
- package/dist/file-changes.d.ts +12 -0
- package/dist/file-changes.js +235 -0
- package/dist/help.js +4 -8
- package/dist/host-ledger-file-adapter.d.ts +15 -13
- package/dist/host-ledger-file-adapter.js +131 -79
- package/dist/host-ledger-root.d.ts +2 -0
- package/dist/host-ledger-root.js +9 -0
- package/dist/host-ledger.d.ts +4 -24
- package/dist/inbox.d.ts +10 -5
- package/dist/inbox.js +152 -67
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +122 -0
- package/dist/mention-parse.d.ts +8 -0
- package/dist/mention-parse.js +107 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +4 -1
- package/dist/notifications.d.ts +1 -0
- package/dist/notifications.js +25 -20
- package/dist/participant-host.js +2 -2
- package/dist/ports.d.ts +2 -9
- package/dist/presentation.d.ts +12 -1
- package/dist/presentation.js +42 -12
- package/dist/presented.js +2 -1
- package/dist/registry.d.ts +8 -4
- package/dist/registry.js +41 -74
- package/dist/routes.d.ts +19 -2
- package/dist/routes.js +48 -5
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +2 -0
- package/dist/square-actions.d.ts +26 -13
- package/dist/square-actions.js +200 -84
- package/dist/square-application.d.ts +58 -0
- package/dist/square-application.js +183 -0
- package/dist/square-facade.d.ts +24 -9
- package/dist/square-file-adapter.d.ts +5 -12
- package/dist/square-file-adapter.js +12 -64
- package/dist/square-projections.d.ts +3 -2
- package/dist/square-projections.js +12 -3
- package/dist/square-storage.js +27 -34
- package/dist/square-wiring.d.ts +12 -7
- package/dist/square-wiring.js +36 -32
- package/dist/state-cell.d.ts +1 -1
- package/dist/stream.js +30 -26
- package/dist/views.d.ts +3 -3
- package/dist/views.js +4 -4
- package/dist/wake-attempts.js +2 -1
- package/dist/wake-evidence.js +2 -8
- package/dist/wakes.d.ts +1 -0
- package/dist/wakes.js +5 -5
- package/dist/watch.d.ts +6 -1
- package/dist/watch.js +20 -18
- package/extensions/square-pi.js +100 -112
- package/guides/application-boundary.md +32 -0
- package/guides/change-waits.md +28 -0
- package/guides/host-ledger.md +9 -0
- package/guides/mcp.md +9 -0
- package/guides/operation-cancellation.md +17 -0
- package/package.json +8 -2
- package/skills/square/SKILL.md +5 -7
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Filesystem notifications are hints, never proof of a committed change. */
|
|
4
|
+
const RECONCILE_MS = 2_000;
|
|
5
|
+
export async function canonicalFilePath(value) {
|
|
6
|
+
let current = path.resolve(value);
|
|
7
|
+
const suffix = [];
|
|
8
|
+
for (;;) {
|
|
9
|
+
try {
|
|
10
|
+
return path.join(await fs.promises.realpath(current), ...suffix.reverse());
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== 'ENOENT')
|
|
14
|
+
return path.resolve(value);
|
|
15
|
+
const parent = path.dirname(current);
|
|
16
|
+
if (parent === current)
|
|
17
|
+
return path.resolve(value);
|
|
18
|
+
suffix.push(path.basename(current));
|
|
19
|
+
current = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A local edge counter: register first, inspect state, then wait from that counter. */
|
|
24
|
+
class Edges {
|
|
25
|
+
version = 0;
|
|
26
|
+
listeners = new Set();
|
|
27
|
+
emit() { this.version += 1; for (const listener of [...this.listeners])
|
|
28
|
+
listener(); }
|
|
29
|
+
wait(since, timeoutMs, signal) {
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
return Promise.reject(signal.reason ?? new Error('Wait aborted'));
|
|
32
|
+
if (this.version !== since)
|
|
33
|
+
return Promise.resolve(true);
|
|
34
|
+
if (timeoutMs <= 0)
|
|
35
|
+
return Promise.resolve(false);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let timer;
|
|
38
|
+
const cleanup = () => { if (timer !== undefined)
|
|
39
|
+
clearTimeout(timer); this.listeners.delete(changed); signal?.removeEventListener('abort', aborted); };
|
|
40
|
+
const changed = () => { cleanup(); resolve(true); };
|
|
41
|
+
const aborted = () => { cleanup(); reject(signal?.reason ?? new Error('Wait aborted')); };
|
|
42
|
+
this.listeners.add(changed);
|
|
43
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
44
|
+
if (Number.isFinite(timeoutMs))
|
|
45
|
+
timer = setTimeout(() => { cleanup(); resolve(false); }, Math.min(timeoutMs, 2_147_483_647));
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
aborted();
|
|
48
|
+
else if (this.version !== since)
|
|
49
|
+
changed();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const directories = new Map();
|
|
54
|
+
function attachDirectory(directory, entry) {
|
|
55
|
+
try {
|
|
56
|
+
const stat = fs.statSync(directory);
|
|
57
|
+
const identity = stat.dev + ':' + stat.ino;
|
|
58
|
+
if (entry.watcher !== undefined && entry.identity === identity)
|
|
59
|
+
return;
|
|
60
|
+
entry.watcher?.close();
|
|
61
|
+
entry.watcher = undefined;
|
|
62
|
+
entry.identity = identity;
|
|
63
|
+
const watcher = fs.watch(directory, (_event, filename) => {
|
|
64
|
+
const name = filename?.toString();
|
|
65
|
+
for (const [target, edges] of entry.targets) {
|
|
66
|
+
if (name === undefined || name === target || name === target + '-journal' || name === target + '-wal') {
|
|
67
|
+
for (const edge of edges)
|
|
68
|
+
edge.emit();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
entry.watcher = watcher;
|
|
73
|
+
watcher.on('error', () => {
|
|
74
|
+
if (entry.watcher !== watcher)
|
|
75
|
+
return;
|
|
76
|
+
watcher.close();
|
|
77
|
+
entry.watcher = undefined;
|
|
78
|
+
for (const edges of entry.targets.values())
|
|
79
|
+
for (const edge of edges)
|
|
80
|
+
edge.emit();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
entry.watcher?.close();
|
|
85
|
+
entry.watcher = undefined;
|
|
86
|
+
// Missing paths, resource limits, and unsupported filesystems use reconciliation.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function subscribeHints(file) {
|
|
90
|
+
const directory = path.dirname(file);
|
|
91
|
+
const name = path.basename(file);
|
|
92
|
+
let entry = directories.get(directory);
|
|
93
|
+
if (entry === undefined) {
|
|
94
|
+
entry = { targets: new Map() };
|
|
95
|
+
directories.set(directory, entry);
|
|
96
|
+
const owned = entry;
|
|
97
|
+
entry.timer = setInterval(() => {
|
|
98
|
+
attachDirectory(directory, owned);
|
|
99
|
+
for (const edges of owned.targets.values())
|
|
100
|
+
for (const edge of edges)
|
|
101
|
+
edge.emit();
|
|
102
|
+
}, RECONCILE_MS);
|
|
103
|
+
}
|
|
104
|
+
const edges = new Edges();
|
|
105
|
+
const targets = entry.targets.get(name) ?? new Set();
|
|
106
|
+
targets.add(edges);
|
|
107
|
+
entry.targets.set(name, targets);
|
|
108
|
+
attachDirectory(directory, entry);
|
|
109
|
+
let closed = false;
|
|
110
|
+
return { edges, close() {
|
|
111
|
+
if (closed)
|
|
112
|
+
return;
|
|
113
|
+
closed = true;
|
|
114
|
+
targets.delete(edges);
|
|
115
|
+
if (targets.size === 0)
|
|
116
|
+
entry.targets.delete(name);
|
|
117
|
+
if (entry.targets.size === 0) {
|
|
118
|
+
entry.watcher?.close();
|
|
119
|
+
clearInterval(entry.timer);
|
|
120
|
+
directories.delete(directory);
|
|
121
|
+
}
|
|
122
|
+
} };
|
|
123
|
+
}
|
|
124
|
+
/** Await a shared read without allowing one subscriber to cancel other subscribers. */
|
|
125
|
+
async function abortable(promise, signal) {
|
|
126
|
+
if (signal?.aborted)
|
|
127
|
+
throw signal.reason ?? new Error('Wait aborted');
|
|
128
|
+
if (signal === undefined)
|
|
129
|
+
return promise;
|
|
130
|
+
let abort;
|
|
131
|
+
try {
|
|
132
|
+
return await Promise.race([promise, new Promise((_resolve, reject) => {
|
|
133
|
+
abort = () => reject(signal.reason ?? new Error('Wait aborted'));
|
|
134
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
135
|
+
if (signal.aborted)
|
|
136
|
+
abort();
|
|
137
|
+
})]);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (abort !== undefined)
|
|
141
|
+
signal.removeEventListener('abort', abort);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const versions = new Map();
|
|
145
|
+
/** Shared only within this process. Durable state is always read at the storage boundary. */
|
|
146
|
+
export async function observeFileVersion(filePath, kind, read) {
|
|
147
|
+
const file = await canonicalFilePath(filePath);
|
|
148
|
+
const key = kind + '\0' + file;
|
|
149
|
+
let shared = versions.get(key);
|
|
150
|
+
if (shared === undefined) {
|
|
151
|
+
shared = { refs: 0, hints: subscribeHints(file), controller: new AbortController() };
|
|
152
|
+
versions.set(key, shared);
|
|
153
|
+
}
|
|
154
|
+
shared.refs += 1;
|
|
155
|
+
const entry = shared;
|
|
156
|
+
const local = new AbortController();
|
|
157
|
+
const readVersion = async (signal) => {
|
|
158
|
+
const opSignal = signal === undefined ? local.signal : AbortSignal.any([local.signal, signal]);
|
|
159
|
+
if (opSignal.aborted)
|
|
160
|
+
throw opSignal.reason;
|
|
161
|
+
const generation = entry.hints.edges.version;
|
|
162
|
+
if (entry.cached?.generation === generation)
|
|
163
|
+
return entry.cached.value;
|
|
164
|
+
if (entry.pending === undefined) {
|
|
165
|
+
entry.pending = read(file, entry.controller.signal).then((value) => {
|
|
166
|
+
entry.cached = { generation, value };
|
|
167
|
+
return value;
|
|
168
|
+
}).finally(() => { entry.pending = undefined; });
|
|
169
|
+
}
|
|
170
|
+
return abortable(entry.pending, opSignal);
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
get generation() { return entry.hints.edges.version; },
|
|
174
|
+
hinted(since, timeoutMs, signal) {
|
|
175
|
+
return entry.hints.edges.wait(since, timeoutMs, signal === undefined ? local.signal : AbortSignal.any([local.signal, signal]));
|
|
176
|
+
},
|
|
177
|
+
read: readVersion,
|
|
178
|
+
async changed(since, timeoutMs, signal) {
|
|
179
|
+
const timeout = new AbortController();
|
|
180
|
+
const timer = Number.isFinite(timeoutMs) ? setTimeout(() => timeout.abort(), Math.min(Math.max(1, timeoutMs), 2_147_483_647)) : undefined;
|
|
181
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
182
|
+
const opSignal = AbortSignal.any([local.signal, timeout.signal, ...(signal === undefined ? [] : [signal])]);
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const edge = entry.hints.edges.version;
|
|
186
|
+
if (!Object.is(await readVersion(opSignal), since))
|
|
187
|
+
return true;
|
|
188
|
+
const remaining = deadline - Date.now();
|
|
189
|
+
if (remaining <= 0)
|
|
190
|
+
return false;
|
|
191
|
+
// Another subscriber's in-flight read may predate the edge captured above.
|
|
192
|
+
if (entry.cached?.generation !== entry.hints.edges.version)
|
|
193
|
+
continue;
|
|
194
|
+
if (!await entry.hints.edges.wait(edge, remaining, opSignal))
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (signal?.aborted)
|
|
200
|
+
throw signal.reason ?? error;
|
|
201
|
+
if (local.signal.aborted || timeout.signal.aborted)
|
|
202
|
+
return false;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (timer !== undefined)
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
close() {
|
|
211
|
+
if (local.signal.aborted)
|
|
212
|
+
return;
|
|
213
|
+
local.abort(new Error('Change subscription closed'));
|
|
214
|
+
if (--entry.refs === 0) {
|
|
215
|
+
entry.controller.abort(new Error('Change monitor closed'));
|
|
216
|
+
entry.hints.close();
|
|
217
|
+
versions.delete(key);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** File metadata invalidates a ledger projection; it is not presentation evidence. */
|
|
223
|
+
export function observeFileMetadata(file) {
|
|
224
|
+
return observeFileVersion(file, 'metadata', async (target) => {
|
|
225
|
+
try {
|
|
226
|
+
const stat = await fs.promises.stat(target, { bigint: true });
|
|
227
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error.code === 'ENOENT')
|
|
231
|
+
return 'missing';
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const COMMANDS = [
|
|
2
2
|
{ names: ['help'], usage: 'help [command]', summary: 'Show the command index or help for one command.' },
|
|
3
3
|
{ names: ['version'], usage: 'version', summary: 'Print the installed version.', hiddenFromIndex: true },
|
|
4
|
+
{ names: ['mcp-server'], usage: 'mcp-server', summary: 'Start the MCP stdio server.', usesSquare: true, hiddenFromIndex: true, details: ['Set SQUARE_LOCATION and SQUARE_PARTICIPANT_NAME, or pass --location and --as before the command.'] },
|
|
4
5
|
{
|
|
5
6
|
names: ['build'], usage: 'build [--cap <N|unlimited>] [--template <name>] [--throttle N] [-f] < body.md', usesSquare: true, group: 'host',
|
|
6
7
|
summary: 'Create a square from Markdown on stdin.',
|
|
@@ -18,7 +19,7 @@ const COMMANDS = [
|
|
|
18
19
|
{
|
|
19
20
|
names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell) [--reply <activity-id>] <activity | ->', usesSquare: true, group: 'participant',
|
|
20
21
|
summary: 'Speak, gesture, or do both.',
|
|
21
|
-
details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
|
|
22
|
+
details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; @names stay plain text. listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", ' @name An @name in the body also addresses that standing participant; `backticks` keep @text literal.', '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
|
|
22
23
|
},
|
|
23
24
|
{ names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
|
|
24
25
|
{ names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
|
|
@@ -28,7 +29,7 @@ const COMMANDS = [
|
|
|
28
29
|
summary: 'Catch directed conversation since you last looked.',
|
|
29
30
|
details: ['Modes:', ' --id <activity-id> Catch exactly one activity addressed to you, with its full body; implies --now.', ' Marks only this activity seen; repeats return it again. No filters, limit, idle or replace.', ' history stays read-only; catch --id consumes the selected activity.', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Attention:', ' Mentions arrive when you are addressed; bells arrive for everyone; bare says require listen.', ' listen and ignore are future-only and fixed when each say lands.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention Match direct attention for your current participant (and bells).', ' --limit <count> Return one page (default 10, maximum 100).', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
|
|
30
31
|
},
|
|
31
|
-
{ names: ['done'], usage: '--as <name> done
|
|
32
|
+
{ names: ['done'], usage: '--as <name> done', usesSquare: true, group: 'participant', summary: 'Step out of the square.', details: ['done takes no message. Use express before leaving to send one.'] },
|
|
32
33
|
{
|
|
33
34
|
names: ['stream'], usage: 'stream [--ndjson [--for <name>] [--last <N> | --after <id>]]', usesSquare: true, hiddenFromIndex: true,
|
|
34
35
|
summary: 'Follow activity without consuming participant presence.',
|
|
@@ -43,7 +44,7 @@ const COMMANDS = [
|
|
|
43
44
|
{
|
|
44
45
|
names: ['history'], usage: 'history [filters] [output]', usesSquare: true, group: 'participant',
|
|
45
46
|
summary: 'Read or search the archive without changing what you have caught.',
|
|
46
|
-
details: ['Filters:', ' --from <names> Match activities from participants.', ' --since <time> Match activities after a time.', ' --grep <regex> | --fixed <s> Search activity ids, participants, and original bodies.', ' --mention <name> Match direct attention for a participant.', ' --at <ids> Center on comma-separated activity ids; may repeat.', ' -B, -A, -C <N> Set non-negative context around every --at coordinate.', ' --before <id> Read the page immediately before an activity.', ' --after <id> Read the page immediately after an activity.', '', 'Results:', ' --limit <N> Page size (default 10, maximum 100).', ' --order <asc|desc> Set display order (default oldest first).', '', 'Output:', ' --no-truncate --json --format <fields>', ' One result shows its full body; multiple results use previews.', ' --no-truncate shows every original body.'],
|
|
47
|
+
details: ['Optional --as is accepted but does not change the archive or claim a name.', '', 'Filters:', ' --from <names> Match activities from participants.', ' --since <time> Match activities after a time.', ' --grep <regex> | --fixed <s> Search activity ids, participants, and original bodies.', ' --mention <name> Match direct attention for a participant.', ' --at <ids> Center on comma-separated activity ids; may repeat.', ' -B, -A, -C <N> Set non-negative context around every --at coordinate.', ' --before <id> Read the page immediately before an activity.', ' --after <id> Read the page immediately after an activity.', '', 'Results:', ' --limit <N> Page size (default 10, maximum 100).', ' --order <asc|desc> Set display order (default oldest first).', '', 'Output:', ' --no-truncate --json --format <fields>', ' One result shows its full body; multiple results use previews.', ' --no-truncate shows every original body.'],
|
|
47
48
|
},
|
|
48
49
|
{ names: ['status'], usage: '[--as <name>] status', usesSquare: true, group: 'participant', summary: 'Show who is present and what happened most recently.' },
|
|
49
50
|
{
|
|
@@ -125,22 +126,17 @@ export function renderSubcommandHelp(command) {
|
|
|
125
126
|
}
|
|
126
127
|
export function helpRequest(rawArgs) {
|
|
127
128
|
const args = [];
|
|
128
|
-
let hasExplicitName = false;
|
|
129
129
|
for (let index = 0; index < rawArgs.length; index++) {
|
|
130
130
|
const arg = rawArgs[index];
|
|
131
131
|
if (arg === '--location' || arg === '--as') {
|
|
132
132
|
const value = rawArgs[index + 1];
|
|
133
133
|
if (value === undefined || value.startsWith('--'))
|
|
134
134
|
return undefined;
|
|
135
|
-
if (arg === '--as')
|
|
136
|
-
hasExplicitName = true;
|
|
137
135
|
index++;
|
|
138
136
|
continue;
|
|
139
137
|
}
|
|
140
138
|
args.push(arg);
|
|
141
139
|
}
|
|
142
|
-
if (hasExplicitName && args[0] === 'history')
|
|
143
|
-
return undefined;
|
|
144
140
|
if (isHelpFlag(args[0]))
|
|
145
141
|
return {};
|
|
146
142
|
if (args[0] === 'help') {
|
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type VersionObserver } from './file-changes.js';
|
|
2
|
+
import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
|
|
2
3
|
export interface HostLedgerFileAdapterOptions {
|
|
3
|
-
|
|
4
|
-
localPath?: string;
|
|
5
|
-
writableScope?: HostLedgerScope;
|
|
6
|
-
readableScopes?: readonly HostLedgerScope[];
|
|
4
|
+
rootPath?: string;
|
|
7
5
|
claimsPath?: string;
|
|
8
6
|
now?: () => number;
|
|
9
7
|
}
|
|
10
8
|
export declare class FileHostLedgerPort implements HostLedgerPort {
|
|
11
|
-
private readonly
|
|
12
|
-
private readonly local;
|
|
13
|
-
private readonly writable;
|
|
14
|
-
private readonly readable;
|
|
9
|
+
private readonly root;
|
|
15
10
|
private readonly claims;
|
|
16
11
|
private readonly clock;
|
|
17
12
|
constructor(o?: HostLedgerFileAdapterOptions);
|
|
18
13
|
private file;
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
/** Invalidation only: callers still use the ledger projections as their authority. */
|
|
15
|
+
observeChanges(): Promise<{
|
|
16
|
+
presence: VersionObserver<string>;
|
|
17
|
+
evidence: VersionObserver<string>;
|
|
18
|
+
}>;
|
|
19
|
+
/** Time can expire bindings/evidence even when their files do not change. */
|
|
20
|
+
nextExpiry(session: string): Promise<number>;
|
|
21
|
+
claimPresence(i: PresenceRecord, signal?: AbortSignal): Promise<PresenceClaimResult>;
|
|
22
|
+
ensurePresence(i: PresenceRecord): Promise<PresenceResult>;
|
|
21
23
|
removePresence(i: PresenceKey): Promise<void>;
|
|
24
|
+
removePresenceIfUnchanged(i: PresenceRecord): Promise<boolean>;
|
|
22
25
|
listPresence(i?: PresenceLookup): Promise<readonly PresenceRecord[]>;
|
|
23
26
|
claimWakeDispatch(i: WakeDispatchClaimInput): Promise<WakeDispatchClaim>;
|
|
24
27
|
transitionWakeDispatch(i: WakeDispatchTransitionInput): Promise<boolean>;
|
|
@@ -34,6 +37,5 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
|
|
|
34
37
|
}): Promise<void>;
|
|
35
38
|
listEvidence(i?: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
|
|
36
39
|
gcEvidence(i: EvidenceGc): Promise<void>;
|
|
37
|
-
reconcileBinding(i?: ReconcileBindingInput): Promise<ReconcileBindingResult>;
|
|
38
40
|
}
|
|
39
|
-
export declare function createHostLedgerPort(o?: HostLedgerFileAdapterOptions):
|
|
41
|
+
export declare function createHostLedgerPort(o?: HostLedgerFileAdapterOptions): FileHostLedgerPort;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import fsSync from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
3
|
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
6
|
import { withFileLock as acquireFileLock } from './file-lock.js';
|
|
6
7
|
import { nameKey } from './model.js';
|
|
7
|
-
import {
|
|
8
|
+
import { hostLedgerRoot } from './host-ledger-root.js';
|
|
9
|
+
import { observeFileMetadata } from './file-changes.js';
|
|
8
10
|
const LOCK = { retryMs: 10 };
|
|
9
11
|
const RETENTION = 7 * 86400000;
|
|
10
12
|
// Prepare before locking: lock databases and their journals are local runtime data too.
|
|
11
13
|
async function withFileLock(file, options, fn) {
|
|
14
|
+
if (options.signal?.aborted)
|
|
15
|
+
throw options.signal.reason ?? new Error('File lock acquisition aborted');
|
|
12
16
|
const directory = path.dirname(file);
|
|
13
17
|
await fs.mkdir(directory, { recursive: true });
|
|
14
18
|
try {
|
|
@@ -56,8 +60,35 @@ catch (e) {
|
|
|
56
60
|
return [];
|
|
57
61
|
throw e;
|
|
58
62
|
} }
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
/** Keep the old ledger intact while Windows readers briefly deny replacement.
|
|
64
|
+
* The owning file lock stays held throughout; never unlink the destination. */
|
|
65
|
+
async function write(file, rows, signal) {
|
|
66
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
67
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
68
|
+
try {
|
|
69
|
+
if (signal?.aborted)
|
|
70
|
+
throw signal.reason ?? new Error('Ledger write aborted');
|
|
71
|
+
await fs.writeFile(tmp, rows.length ? rows.map((row) => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600, flag: 'wx' });
|
|
72
|
+
for (let attempt = 0;; attempt += 1) {
|
|
73
|
+
if (signal?.aborted)
|
|
74
|
+
throw signal.reason ?? new Error('Ledger write aborted');
|
|
75
|
+
try {
|
|
76
|
+
await fs.rename(tmp, file);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const code = error.code;
|
|
81
|
+
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
|
|
82
|
+
throw error;
|
|
83
|
+
await sleep(10 * 2 ** attempt, undefined, { signal });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
|
|
61
92
|
function evidenceKey(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.activity, v.kind, v.attemptN ?? null]); }
|
|
62
93
|
async function readClaims(file, now) { try {
|
|
63
94
|
return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
|
|
@@ -81,50 +112,103 @@ function processAlive(pid) { if (pid === undefined)
|
|
|
81
112
|
catch (error) {
|
|
82
113
|
return error.code === 'ESRCH' ? false : true;
|
|
83
114
|
} }
|
|
84
|
-
|
|
115
|
+
function writeClaims(file, rows) { return write(file, rows); }
|
|
85
116
|
export class FileHostLedgerPort {
|
|
86
|
-
|
|
87
|
-
local;
|
|
88
|
-
writable;
|
|
89
|
-
readable;
|
|
117
|
+
root;
|
|
90
118
|
claims;
|
|
91
119
|
clock;
|
|
92
|
-
constructor(o = {}) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
constructor(o = {}) {
|
|
121
|
+
this.root = canonRoot(o.rootPath ?? hostLedgerRoot());
|
|
122
|
+
this.claims = canonRoot(o.claimsPath ?? path.join(this.root, 'wake-claims.ndjsonl'));
|
|
123
|
+
this.clock = o.now ?? Date.now;
|
|
124
|
+
}
|
|
125
|
+
file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
|
|
126
|
+
/** Invalidation only: callers still use the ledger projections as their authority. */
|
|
127
|
+
async observeChanges() {
|
|
128
|
+
const presence = await observeFileMetadata(this.file('presence'));
|
|
129
|
+
try {
|
|
130
|
+
return { presence, evidence: await observeFileMetadata(this.file('evidence')) };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
presence.close();
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Time can expire bindings/evidence even when their files do not change. */
|
|
138
|
+
async nextExpiry(session) {
|
|
139
|
+
const now = this.clock();
|
|
140
|
+
const rows = [...await read(this.file('presence'), now, true),
|
|
141
|
+
...await read(this.file('evidence'), now, true)].filter((row) => row.session === session);
|
|
142
|
+
let next = Infinity;
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const at = 'updatedAt' in row ? row.updatedAt : 'at' in row ? row.at : undefined;
|
|
145
|
+
if (typeof at === 'number') {
|
|
146
|
+
if (at > now)
|
|
147
|
+
next = Math.min(next, at);
|
|
148
|
+
if (at + RETENTION + 1 > now && !('kind' in row && row.kind === 'wake' && row.outcome === 'accepted'))
|
|
149
|
+
next = Math.min(next, at + RETENTION + 1);
|
|
100
150
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} 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 }; });
|
|
151
|
+
if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
|
|
152
|
+
next = Math.min(next, row.expiresAt);
|
|
153
|
+
}
|
|
154
|
+
return next;
|
|
106
155
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
156
|
+
async claimPresence(i, signal) {
|
|
157
|
+
const location = await canon(i.location);
|
|
158
|
+
const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
|
|
159
|
+
try {
|
|
160
|
+
return await withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, async () => {
|
|
161
|
+
const file = this.file('presence');
|
|
162
|
+
// The authoritative read, ownership check, and write share the presence file lock:
|
|
163
|
+
// a concurrent ensurePresence/removePresence between them must never be overwritten
|
|
164
|
+
// by a stale snapshot.
|
|
165
|
+
return withFileLock(file + '.lock', { ...LOCK, signal }, async () => {
|
|
166
|
+
const rows = await read(file, this.clock(), true);
|
|
167
|
+
const existing = rows.filter((row) => row.location === location && nameKey(row.participant) === nameKey(record.participant)).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
|
|
168
|
+
if (existing !== undefined)
|
|
169
|
+
return existing.session === record.session ? { status: 'owned', record: existing } : { status: 'busy', record: existing };
|
|
170
|
+
await write(file, [...rows, record], signal);
|
|
171
|
+
return { status: 'acquired', record };
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return { status: 'degraded', record, error };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async ensurePresence(i) {
|
|
180
|
+
const record = { ...i, location: await canon(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
|
|
181
|
+
try {
|
|
182
|
+
await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), [...(await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record)), record]));
|
|
183
|
+
return { status: 'ensured', record };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return { status: 'degraded', record, error };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async removePresence(i) {
|
|
190
|
+
const record = { ...i, location: await canon(i.location) };
|
|
191
|
+
await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), (await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record))));
|
|
192
|
+
}
|
|
193
|
+
async removePresenceIfUnchanged(i) {
|
|
194
|
+
const record = { ...i, location: await canon(i.location) };
|
|
195
|
+
return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
|
|
196
|
+
const file = this.file('presence');
|
|
197
|
+
const rows = await read(file, this.clock(), true);
|
|
198
|
+
const matches = (row) => k(row) === k(record)
|
|
199
|
+
&& (row.epoch ?? undefined) === (record.epoch ?? undefined)
|
|
200
|
+
&& (row.updatedAt ?? undefined) === (record.updatedAt ?? undefined);
|
|
201
|
+
const current = rows.findLast(matches);
|
|
202
|
+
if (current === undefined)
|
|
203
|
+
return false;
|
|
204
|
+
await write(file, rows.filter((row) => row !== current));
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async listPresence(i = {}) {
|
|
209
|
+
const location = i.location === undefined ? undefined : await canon(i.location);
|
|
210
|
+
return (await read(this.file('presence'), i.now ?? this.clock())).filter((row) => (!location || row.location === location) && (!i.participant || nameKey(row.participant) === nameKey(i.participant)) && (!i.session || row.session === i.session)).map(({ scope: _scope, ...row }) => row);
|
|
113
211
|
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
return { status: 'degraded', record: r, error };
|
|
116
|
-
} }
|
|
117
|
-
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); }))); }
|
|
118
|
-
async listPresence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location), m = new Map(); for (const s of i.scopes ?? this.readable)
|
|
119
|
-
for (const r of await read(this.file(s, 'presence'), i.now ?? this.clock())) {
|
|
120
|
-
if ((r.scope ?? s) !== s || loc && r.location !== loc || i.participant && nameKey(r.participant) !== nameKey(i.participant) || i.session && r.session !== i.session)
|
|
121
|
-
continue;
|
|
122
|
-
const x = s === 'user' ? r : { ...r, route: undefined }, key = k({ ...x, scope: undefined }), old = m.get(key);
|
|
123
|
-
if (old?.scope === 'user' && s === 'local')
|
|
124
|
-
continue;
|
|
125
|
-
if (!old || s === 'user' || x.updatedAt >= old.record.updatedAt)
|
|
126
|
-
m.set(key, { record: x, scope: s });
|
|
127
|
-
} return [...m.values()].map(({ record }) => { const { scope: _scope, ...publicRecord } = record; return publicRecord; }); }
|
|
128
212
|
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') {
|
|
129
213
|
const alive = processAlive(existing.ownerPid);
|
|
130
214
|
if (existing.expiresAt <= at || alive === false)
|
|
@@ -145,7 +229,7 @@ export class FileHostLedgerPort {
|
|
|
145
229
|
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 }); }
|
|
146
230
|
async appendWakeAttempt(i) { if (i.kind !== 'wake')
|
|
147
231
|
throw new Error('Wake attempt rows require wake evidence kind'); await this.appendEvidence(i); }
|
|
148
|
-
async claimEvidence(i) { const f = this.file('
|
|
232
|
+
async claimEvidence(i) { const f = this.file('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 {
|
|
149
233
|
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)
|
|
150
234
|
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)
|
|
151
235
|
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 }; });
|
|
@@ -153,44 +237,12 @@ export class FileHostLedgerPort {
|
|
|
153
237
|
catch (error) {
|
|
154
238
|
return { status: 'degraded', error };
|
|
155
239
|
} }
|
|
156
|
-
async releaseEvidence(i) { const f = this.file('
|
|
240
|
+
async releaseEvidence(i) { const f = this.file('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)
|
|
157
241
|
return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
|
|
158
|
-
async appendEvidence(i) { const f = this.file('
|
|
242
|
+
async appendEvidence(i) { const f = this.file('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)
|
|
159
243
|
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)
|
|
160
244
|
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]); }); }
|
|
161
|
-
async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('
|
|
162
|
-
async gcEvidence(i) { const f = this.file('
|
|
163
|
-
async reconcileBinding(i = {}) { let b = []; try {
|
|
164
|
-
b = [...await this.listPresence({ scopes: i.scopes, now: i.now })];
|
|
165
|
-
if (i.artifact) {
|
|
166
|
-
const { state } = await i.artifact.read();
|
|
167
|
-
const stale = b.filter(x => !isCurrentlyJoined(state.acts, x.participant));
|
|
168
|
-
for (const x of stale) {
|
|
169
|
-
for (const scope of i.scopes ?? this.readable) {
|
|
170
|
-
const remover = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: scope, readableScopes: [scope], now: this.clock });
|
|
171
|
-
await remover.removePresence(x);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
b = b.filter(x => !stale.includes(x));
|
|
175
|
-
}
|
|
176
|
-
const latest = new Map();
|
|
177
|
-
for (const x of b) {
|
|
178
|
-
const key = JSON.stringify([x.location, nameKey(x.participant), x.session]);
|
|
179
|
-
const current = latest.get(key);
|
|
180
|
-
if (current === undefined || ((x.updatedAt ?? 0) > (current.updatedAt ?? 0)))
|
|
181
|
-
latest.set(key, x);
|
|
182
|
-
}
|
|
183
|
-
const winners = [...latest.values()];
|
|
184
|
-
const u = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: 'user', readableScopes: ['user'], now: this.clock });
|
|
185
|
-
for (const x of winners) {
|
|
186
|
-
const result = await u.ensurePresence(x);
|
|
187
|
-
if (result.status === 'degraded')
|
|
188
|
-
return { status: 'degraded', bindings: winners, error: result.error };
|
|
189
|
-
}
|
|
190
|
-
return { status: 'reconciled', bindings: winners };
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
return { status: 'degraded', bindings: b, error };
|
|
194
|
-
} }
|
|
245
|
+
async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(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)); }
|
|
246
|
+
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 || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
|
|
195
247
|
}
|
|
196
248
|
export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
/** Resolve the single user-level host ledger root. */
|
|
4
|
+
export function hostLedgerRoot(env = process.env) {
|
|
5
|
+
return env.SQUARE_HOST_LEDGER_ROOT
|
|
6
|
+
?? (env.SQUARE_REGISTRY === undefined
|
|
7
|
+
? path.join(homedir(), '.square', 'host-ledger')
|
|
8
|
+
: path.dirname(env.SQUARE_REGISTRY));
|
|
9
|
+
}
|