@astrosheep/square 0.3.63 → 0.3.64
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 +3 -2
- package/claude-plugin/hooks/hooks.json +1 -22
- package/claude-plugin/hooks/register.js +119 -0
- package/claude-plugin/types.d.ts +7 -0
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/activity-feed.d.ts +1 -10
- package/dist/activity-feed.js +1 -27
- package/dist/activity.js +57 -25
- package/dist/artifact.js +12 -24
- package/dist/attention-presentation.js +1 -0
- package/dist/automatic-session.d.ts +3 -1
- package/dist/automatic-session.js +21 -34
- package/dist/boundary-presentation.js +4 -13
- package/dist/canonical-path.d.ts +11 -0
- package/dist/canonical-path.js +47 -0
- package/dist/catch-decisions.js +14 -10
- package/dist/catch-selection.d.ts +6 -0
- package/dist/catch-selection.js +14 -0
- package/dist/claude-delivery.d.ts +11 -0
- package/dist/claude-delivery.js +145 -0
- package/dist/claude-mod.d.ts +8 -0
- package/dist/claude-mod.js +164 -0
- package/dist/cli/context.d.ts +0 -1
- package/dist/cli/context.js +9 -21
- package/dist/cli/harness-command.d.ts +0 -1
- package/dist/cli/harness-command.js +0 -6
- package/dist/cli/observation-commands.d.ts +1 -1
- package/dist/cli/observation-commands.js +31 -58
- package/dist/cli/program.js +20 -18
- package/dist/cli/registry.js +2 -2
- package/dist/cli/square-commands.js +10 -6
- package/dist/codex-hook.js +9 -12
- package/dist/codex-queue.d.ts +2 -2
- package/dist/codex-queue.js +7 -5
- package/dist/decisions.d.ts +2 -6
- package/dist/decisions.js +16 -17
- package/dist/delivery-health.d.ts +1 -2
- package/dist/delivery-health.js +4 -5
- package/dist/delivery-operations.d.ts +2 -5
- package/dist/delivery-operations.js +77 -214
- package/dist/delivery.d.ts +25 -47
- package/dist/delivery.js +14 -46
- package/dist/diagnostic-redaction.js +7 -9
- package/dist/file-changes.d.ts +0 -1
- package/dist/file-changes.js +2 -19
- package/dist/harness-claude.js +8 -6
- package/dist/harness-codex.d.ts +0 -2
- package/dist/harness-codex.js +0 -2
- package/dist/harness-links.js +13 -17
- package/dist/harness.js +3 -7
- package/dist/help.js +4 -3
- package/dist/host-ledger-file-adapter.d.ts +17 -8
- package/dist/host-ledger-file-adapter.js +164 -80
- package/dist/host-ledger.d.ts +55 -50
- package/dist/inbox.d.ts +0 -3
- package/dist/inbox.js +13 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/list.js +1 -4
- package/dist/mcp.js +18 -40
- package/dist/mention-parse.js +3 -9
- package/dist/model.d.ts +11 -2
- package/dist/model.js +1 -1
- package/dist/notifications.d.ts +3 -16
- package/dist/notifications.js +41 -122
- package/dist/opencode-delivery.d.ts +14 -0
- package/dist/opencode-delivery.js +171 -0
- package/dist/opencode.d.ts +39 -18
- package/dist/opencode.js +145 -40
- package/dist/packages/agent-delivery/src/claude-auth.d.ts +13 -0
- package/dist/packages/agent-delivery/src/claude-auth.js +70 -0
- package/dist/packages/agent-delivery/src/claude-native.d.ts +28 -0
- package/dist/packages/agent-delivery/src/claude-native.js +65 -0
- package/dist/packages/agent-delivery/src/claude.d.ts +14 -0
- package/dist/packages/agent-delivery/src/claude.js +66 -0
- package/dist/packages/agent-delivery/src/deadline.d.ts +11 -0
- package/dist/packages/agent-delivery/src/deadline.js +41 -0
- package/dist/packages/agent-delivery/src/errors.d.ts +16 -0
- package/dist/packages/agent-delivery/src/errors.js +41 -0
- package/dist/packages/agent-delivery/src/guards.d.ts +6 -0
- package/dist/packages/agent-delivery/src/guards.js +6 -0
- package/dist/packages/agent-delivery/src/index.d.ts +83 -0
- package/dist/packages/agent-delivery/src/index.js +110 -0
- package/dist/packages/agent-delivery/src/local-endpoint.d.ts +26 -0
- package/dist/packages/agent-delivery/src/local-endpoint.js +114 -0
- package/dist/packages/agent-delivery/src/opencode-native.d.ts +33 -0
- package/dist/packages/agent-delivery/src/opencode-native.js +75 -0
- package/dist/packages/agent-delivery/src/opencode.d.ts +14 -0
- package/dist/packages/agent-delivery/src/opencode.js +127 -0
- package/dist/packages/agent-delivery/src/paseo-native.d.ts +58 -0
- package/dist/packages/agent-delivery/src/paseo-native.js +122 -0
- package/dist/packages/agent-delivery/src/paseo.d.ts +19 -0
- package/dist/packages/agent-delivery/src/paseo.js +106 -0
- package/dist/packages/agent-delivery/src/pi-client.d.ts +10 -0
- package/dist/packages/agent-delivery/src/pi-client.js +106 -0
- package/dist/packages/agent-delivery/src/pi-protocol.d.ts +15 -0
- package/dist/packages/agent-delivery/src/pi-protocol.js +50 -0
- package/dist/packages/agent-delivery/src/pi-send.d.ts +23 -0
- package/dist/packages/agent-delivery/src/pi-send.js +4 -0
- package/dist/packages/agent-delivery/src/pi.d.ts +25 -0
- package/dist/packages/agent-delivery/src/pi.js +209 -0
- package/dist/packages/agent-delivery/src/types.d.ts +120 -0
- package/dist/participant-host.js +12 -13
- package/dist/participant-identity.d.ts +21 -0
- package/dist/participant-identity.js +32 -11
- package/dist/paseo-delivery.d.ts +2 -2
- package/dist/paseo-delivery.js +35 -27
- package/dist/paseo-timeline.js +25 -21
- package/dist/paseo.d.ts +2 -2
- package/dist/paseo.js +1 -2
- package/dist/ports.d.ts +7 -20
- package/dist/presentation-operations.js +16 -4
- package/dist/presentation.d.ts +19 -10
- package/dist/presentation.js +99 -177
- package/dist/registry.d.ts +15 -66
- package/dist/registry.js +45 -123
- package/dist/routes.d.ts +1 -28
- package/dist/routes.js +22 -104
- package/dist/runtime.d.ts +3 -9
- package/dist/runtime.js +14 -67
- package/dist/search.d.ts +0 -2
- package/dist/search.js +3 -10
- package/dist/square-actions.js +48 -72
- package/dist/square-application.js +25 -23
- package/dist/square-core.d.ts +3 -0
- package/dist/square-core.js +22 -5
- package/dist/square-facade.d.ts +2 -0
- package/dist/square-file-adapter.js +4 -7
- package/dist/square-projections.d.ts +40 -45
- package/dist/square-projections.js +43 -73
- package/dist/square-state.js +1 -4
- package/dist/square-storage.d.ts +5 -8
- package/dist/square-storage.js +13 -36
- package/dist/square-wiring.d.ts +0 -5
- package/dist/square-wiring.js +3 -21
- package/dist/stream.js +4 -6
- package/dist/time.js +2 -7
- package/dist/views.d.ts +4 -16
- package/dist/views.js +12 -19
- package/dist/wake-attempts.d.ts +2 -56
- package/dist/wake-attempts.js +15 -110
- package/dist/wake-eligibility.d.ts +43 -0
- package/dist/wake-eligibility.js +106 -0
- package/dist/wake-evidence.d.ts +32 -9
- package/dist/wake-evidence.js +29 -24
- package/dist/wake-sink.d.ts +13 -9
- package/dist/wake-sink.js +35 -54
- package/dist/watch.js +13 -21
- package/extensions/square-pi.js +10 -5
- package/guides/claude-native.md +101 -0
- package/guides/host-ledger.md +3 -1
- package/guides/participant.md +14 -7
- package/guides/pi-native-delivery.md +43 -0
- package/package.json +4 -3
- package/dist/claude-hook.d.ts +0 -10
- package/dist/claude-hook.js +0 -60
- package/dist/paseo-connection.d.ts +0 -15
- package/dist/paseo-connection.js +0 -141
- package/dist/presence.d.ts +0 -2
- package/dist/presence.js +0 -24
- package/dist/presented.d.ts +0 -2
- package/dist/presented.js +0 -10
- package/dist/state-cell.d.ts +0 -14
- package/dist/wake-port.d.ts +0 -23
- package/dist/wake-port.js +0 -27
- /package/dist/{state-cell.js → packages/agent-delivery/src/types.js} +0 -0
package/dist/file-changes.js
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { canonicalPath } from './canonical-path.js';
|
|
3
4
|
/** Filesystem notifications are hints, never proof of a committed change. */
|
|
4
5
|
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
6
|
/** A local edge counter: register first, inspect state, then wait from that counter. */
|
|
24
7
|
class Edges {
|
|
25
8
|
version = 0;
|
|
@@ -144,7 +127,7 @@ async function abortable(promise, signal) {
|
|
|
144
127
|
const versions = new Map();
|
|
145
128
|
/** Shared only within this process. Durable state is always read at the storage boundary. */
|
|
146
129
|
export async function observeFileVersion(filePath, kind, read) {
|
|
147
|
-
const file = await
|
|
130
|
+
const file = await canonicalPath(filePath);
|
|
148
131
|
const key = kind + '\0' + file;
|
|
149
132
|
let shared = versions.get(key);
|
|
150
133
|
if (shared === undefined) {
|
package/dist/harness-claude.js
CHANGED
|
@@ -27,10 +27,6 @@ function requireSuccess(result, operation, allowMissing = false) {
|
|
|
27
27
|
return;
|
|
28
28
|
throw new Error(`Claude ${operation} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
|
|
29
29
|
}
|
|
30
|
-
function writeJson(file, value) {
|
|
31
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
-
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
33
|
-
}
|
|
34
30
|
export async function installClaudePlugin(homeDir, run = runClaude) {
|
|
35
31
|
const marketplaceRoot = claudeMarketplaceRoot(homeDir);
|
|
36
32
|
const staged = stageReplacement(marketplaceRoot, (stage) => {
|
|
@@ -39,11 +35,13 @@ export async function installClaudePlugin(homeDir, run = runClaude) {
|
|
|
39
35
|
const skill = path.join(plugin, 'skills', 'square', 'SKILL.md');
|
|
40
36
|
fs.mkdirSync(path.dirname(skill), { recursive: true });
|
|
41
37
|
fs.copyFileSync(path.join(fileURLToPath(new URL('../skills/square/', import.meta.url)), 'SKILL.md'), skill);
|
|
42
|
-
|
|
38
|
+
const manifestPath = path.join(stage, '.claude-plugin', 'marketplace.json');
|
|
39
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
40
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify({
|
|
43
41
|
name: CLAUDE_MARKETPLACE_NAME,
|
|
44
42
|
owner: { name: 'Square' },
|
|
45
43
|
plugins: [{ name: SQUARE_IDENTITY.pluginName, source: './plugins/square' }],
|
|
46
|
-
});
|
|
44
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
47
45
|
});
|
|
48
46
|
try {
|
|
49
47
|
const add = run(homeDir, ['plugin', 'marketplace', 'add', marketplaceRoot]);
|
|
@@ -73,5 +71,9 @@ export async function doctorClaudePlugin(homeDir, run = runClaude) {
|
|
|
73
71
|
return [
|
|
74
72
|
fs.existsSync(bundle) ? `✓ Square Claude plugin bundle ${root}` : `○ Square Claude plugin bundle missing ${root}`,
|
|
75
73
|
installed ? `✓ ${CLAUDE_PLUGIN_ID} installed` : `○ ${CLAUDE_PLUGIN_ID} unavailable`,
|
|
74
|
+
`Claude native inbox: validated on macOS Claude Code 2.1.295 with a trusted, approved interactive mod.`,
|
|
75
|
+
`Installation or a socket alone does not prove receiver availability; absent/disabled mods, headless unapproved mods, other builds/platforms are unavailable.`,
|
|
76
|
+
`Queue admission is not model processing. Queued or held native messages cannot be retracted by Square cancellation.`,
|
|
77
|
+
`Live routes and admission/presentation evidence: square harness doctor delivery. No legacy fallback.`,
|
|
76
78
|
];
|
|
77
79
|
}
|
package/dist/harness-codex.d.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export declare const CODEX_HOOK_COMMAND: "square codex-hook";
|
|
2
|
-
export declare const SQUARE_CODEX_MARKER: "square-codex-hook";
|
|
3
1
|
export declare const CODEX_PLUGIN_ID: "square@astrosheep";
|
|
4
2
|
export declare const CODEX_MARKETPLACE_NAME: "astrosheep";
|
|
5
3
|
export interface CodexCommandResult {
|
package/dist/harness-codex.js
CHANGED
|
@@ -4,8 +4,6 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import crossSpawn from 'cross-spawn';
|
|
5
5
|
import { SQUARE_IDENTITY } from './identity.js';
|
|
6
6
|
import { stageReplacement } from './harness-stage.js';
|
|
7
|
-
export const CODEX_HOOK_COMMAND = SQUARE_IDENTITY.hookCommand;
|
|
8
|
-
export const SQUARE_CODEX_MARKER = SQUARE_IDENTITY.hookMarker;
|
|
9
7
|
export const CODEX_PLUGIN_ID = SQUARE_IDENTITY.pluginId;
|
|
10
8
|
export const CODEX_MARKETPLACE_NAME = SQUARE_IDENTITY.marketplaceName;
|
|
11
9
|
const LEGACY_MARKETPLACES = ['astrosheep-square'];
|
package/dist/harness-links.js
CHANGED
|
@@ -9,22 +9,21 @@ function packageRoot() {
|
|
|
9
9
|
// Emitted modules live in dist; package assets are one level above them.
|
|
10
10
|
return fileURLToPath(new URL('../', import.meta.url));
|
|
11
11
|
}
|
|
12
|
-
function lstatMaybe(target) {
|
|
13
|
-
try {
|
|
14
|
-
return fs.lstatSync(target);
|
|
15
|
-
}
|
|
16
|
-
catch (error) {
|
|
17
|
-
if (error.code === 'ENOENT')
|
|
18
|
-
return undefined;
|
|
19
|
-
throw error;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
12
|
export function installHarnessLinks(links, force = false) {
|
|
23
13
|
const prepared = links.map((link) => {
|
|
24
14
|
if (!fs.existsSync(link.source)) {
|
|
25
15
|
throw new Error(`Harness link source is missing: ${link.source}`);
|
|
26
16
|
}
|
|
27
|
-
const existing =
|
|
17
|
+
const existing = (() => {
|
|
18
|
+
try {
|
|
19
|
+
return fs.lstatSync(link.target);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT')
|
|
23
|
+
return undefined;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
28
27
|
return {
|
|
29
28
|
...link,
|
|
30
29
|
existing,
|
|
@@ -94,16 +93,13 @@ function runOpenCode(homeDir, args) {
|
|
|
94
93
|
throw result.error;
|
|
95
94
|
return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
|
|
96
95
|
}
|
|
97
|
-
function requireOpenCodeSuccess(result, action) {
|
|
98
|
-
if (result.status === 0)
|
|
99
|
-
return;
|
|
100
|
-
throw new Error(`OpenCode ${action} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
|
|
101
|
-
}
|
|
102
96
|
export function installOpenCodePlugin(homeDir, force = false, run = runOpenCode) {
|
|
103
97
|
const args = ['plugin', SQUARE_IDENTITY.packageName, '--global'];
|
|
104
98
|
if (force)
|
|
105
99
|
args.push('--force');
|
|
106
|
-
|
|
100
|
+
const result = run(homeDir, args);
|
|
101
|
+
if (result.status !== 0)
|
|
102
|
+
throw new Error(`OpenCode plugin install failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
|
|
107
103
|
return [SQUARE_IDENTITY.packageName];
|
|
108
104
|
}
|
|
109
105
|
function configPath(homeDir) {
|
package/dist/harness.js
CHANGED
|
@@ -97,14 +97,10 @@ const TARGETS = [
|
|
|
97
97
|
export function harnessTargets() {
|
|
98
98
|
return TARGETS;
|
|
99
99
|
}
|
|
100
|
-
function requireHarnessCapability(target, action) {
|
|
101
|
-
const found = TARGETS.find((candidate) => candidate.name === target);
|
|
102
|
-
if (found === undefined || !found.capabilities.includes(action))
|
|
103
|
-
throw new Error(`Unsupported harness capability: ${action} ${target}`);
|
|
104
|
-
return found;
|
|
105
|
-
}
|
|
106
100
|
export async function executeHarnessTarget(targetName, action, context) {
|
|
107
|
-
const target =
|
|
101
|
+
const target = TARGETS.find((candidate) => candidate.name === targetName);
|
|
102
|
+
if (target === undefined || !target.capabilities.includes(action))
|
|
103
|
+
throw new Error(`Unsupported harness capability: ${action} ${targetName}`);
|
|
108
104
|
const capability = target[action];
|
|
109
105
|
if (capability === undefined)
|
|
110
106
|
throw new Error(`Unsupported harness capability: ${action} ${targetName}`);
|
package/dist/help.js
CHANGED
|
@@ -17,9 +17,9 @@ const COMMANDS = [
|
|
|
17
17
|
details: ['Options:', ' --last <N> Show the last N public activities (default 10, maximum 100).', ' --kick End the standing participant and reclaim the name for this session.'],
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell
|
|
20
|
+
names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell | --reply <activity-id>) <activity | ->', usesSquare: true, group: 'participant',
|
|
21
21
|
summary: 'Speak, gesture, or do both.',
|
|
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
|
+
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); it reaches that activity\'s author.'],
|
|
23
23
|
},
|
|
24
24
|
{ names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
|
|
25
25
|
{ names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
|
|
@@ -40,7 +40,8 @@ const COMMANDS = [
|
|
|
40
40
|
summary: 'Inspect a bounded machine-local notification snapshot for a native session.',
|
|
41
41
|
details: ['Options:', ' --for-session <id> Required harness session id.', ' --limit <N> Snapshot memberships (default 20, maximum 100).', ' --json Emit the bounded snapshot as structured JSON.'],
|
|
42
42
|
},
|
|
43
|
-
{ names: ['
|
|
43
|
+
{ names: ['codex-hook'], usage: '{command}', summary: 'Present pending attention at one native agent boundary.', hiddenFromIndex: true },
|
|
44
|
+
{ names: ['claude-mod'], usage: '{command}', summary: 'Receive a structured operation from the loaded Claude mod.', hiddenFromIndex: true },
|
|
44
45
|
{
|
|
45
46
|
names: ['history'], usage: 'history [filters] [output]', usesSquare: true, group: 'participant',
|
|
46
47
|
summary: 'Read or search the archive without changing what you have caught.',
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { type VersionObserver } from './file-changes.js';
|
|
2
|
-
import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult,
|
|
2
|
+
import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, WakeAttemptClaim, WakeAttemptClaimInput, WakeAttemptLookup } from './host-ledger.js';
|
|
3
3
|
export interface HostLedgerFileAdapterOptions {
|
|
4
4
|
rootPath?: string;
|
|
5
|
-
claimsPath?: string;
|
|
6
5
|
now?: () => number;
|
|
7
6
|
}
|
|
8
7
|
export declare class FileHostLedgerPort implements HostLedgerPort {
|
|
9
8
|
private readonly root;
|
|
10
|
-
private readonly claims;
|
|
11
9
|
private readonly clock;
|
|
12
10
|
constructor(o?: HostLedgerFileAdapterOptions);
|
|
13
11
|
private file;
|
|
@@ -18,20 +16,31 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
|
|
|
18
16
|
}>;
|
|
19
17
|
/** Time can expire bindings/evidence even when their files do not change. */
|
|
20
18
|
nextExpiry(session: string): Promise<number>;
|
|
19
|
+
/** The ownership claim critical section: the same lock as claimPresence and finalize. */
|
|
20
|
+
withClaimLock<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
21
21
|
claimPresence(i: PresenceRecord, signal?: AbortSignal): Promise<PresenceClaimResult>;
|
|
22
22
|
ensurePresence(i: PresenceRecord): Promise<PresenceResult>;
|
|
23
|
+
/** Local batch suppression is monotonic only for the captured host generation. */
|
|
24
|
+
suppressPresence(i: PresenceRecord, through: number): Promise<boolean>;
|
|
23
25
|
removePresence(i: PresenceKey): Promise<void>;
|
|
24
26
|
removePresenceIfUnchanged(i: PresenceRecord): Promise<boolean>;
|
|
25
27
|
listPresence(i?: PresenceLookup): Promise<readonly PresenceRecord[]>;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
/** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
|
|
29
|
+
claimWakeAttempt(i: WakeAttemptClaimInput): Promise<WakeAttemptClaim>;
|
|
30
|
+
/** One locked, token-fenced replacement; each operation owns its state condition. */
|
|
31
|
+
private updateWake;
|
|
29
32
|
listWakeAttempts(i?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
|
|
30
|
-
|
|
33
|
+
prepareNativeWake(i: EvidenceRecord & {
|
|
31
34
|
readonly claimToken: string;
|
|
32
|
-
|
|
35
|
+
readonly routeKind: import('./model.js').WakeRouteKind;
|
|
36
|
+
readonly nativeDelivery: import('./host-ledger.js').NativeDeliveryEvidence;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
confirmWakeAdmission(i: EvidenceRecord & {
|
|
39
|
+
readonly claimToken: string;
|
|
40
|
+
}): Promise<boolean>;
|
|
33
41
|
claimEvidence(i: EvidenceClaim): Promise<ClaimResult>;
|
|
34
42
|
releaseEvidence(i: EvidenceRelease): Promise<void>;
|
|
43
|
+
/** Token-fenced terminal write. A token may only finish the attempt it claimed. */
|
|
35
44
|
appendEvidence(i: EvidenceRecord & {
|
|
36
45
|
readonly claimToken: string;
|
|
37
46
|
}): Promise<void>;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
-
import fsSync from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { randomUUID } from 'node:crypto';
|
|
5
4
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
5
|
import { withFileLock as acquireFileLock } from './file-lock.js';
|
|
7
6
|
import { nameKey } from './model.js';
|
|
7
|
+
import { formatActivityId } from './square-core.js';
|
|
8
8
|
import { hostLedgerRoot } from './host-ledger-root.js';
|
|
9
9
|
import { observeFileMetadata } from './file-changes.js';
|
|
10
|
+
import { canonicalPath, canonicalPathSync } from './canonical-path.js';
|
|
10
11
|
const LOCK = { retryMs: 10 };
|
|
11
12
|
const RETENTION = 7 * 86400000;
|
|
12
13
|
// Prepare before locking: lock databases and their journals are local runtime data too.
|
|
@@ -24,32 +25,12 @@ async function withFileLock(file, options, fn) {
|
|
|
24
25
|
}
|
|
25
26
|
return acquireFileLock(file, options, fn);
|
|
26
27
|
}
|
|
27
|
-
async function canon(value) { const absolute = path.resolve(value); try {
|
|
28
|
-
return await fs.realpath(absolute);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return absolute;
|
|
32
|
-
} }
|
|
33
|
-
function canonRoot(value) { let current = path.resolve(value), suffix = []; for (;;) {
|
|
34
|
-
try {
|
|
35
|
-
return path.join(fsSync.realpathSync.native(current), ...suffix.reverse());
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
if (error.code !== 'ENOENT')
|
|
39
|
-
return path.resolve(value);
|
|
40
|
-
const parent = path.dirname(current);
|
|
41
|
-
if (parent === current)
|
|
42
|
-
return path.resolve(value);
|
|
43
|
-
suffix.push(path.basename(current));
|
|
44
|
-
current = parent;
|
|
45
|
-
}
|
|
46
|
-
} }
|
|
47
28
|
async function read(file, now, includeFuture = false) { try {
|
|
48
29
|
return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
|
|
49
|
-
const
|
|
30
|
+
const parsed = JSON.parse(line);
|
|
31
|
+
const row = decodeWakeRecord(parsed);
|
|
50
32
|
const at = row.at ?? row.updatedAt;
|
|
51
|
-
|
|
52
|
-
return row.v === 1 && typeof at === 'number' && (durableTerminal || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
|
|
33
|
+
return row.v === 1 && typeof at === 'number' && (durableWake(row) || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
|
|
53
34
|
}
|
|
54
35
|
catch {
|
|
55
36
|
return [];
|
|
@@ -89,21 +70,20 @@ async function write(file, rows, signal) {
|
|
|
89
70
|
}
|
|
90
71
|
}
|
|
91
72
|
function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
73
|
+
function durableWake(record) {
|
|
74
|
+
const row = record;
|
|
75
|
+
return row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown' || row.outcome === 'dispatching');
|
|
76
|
+
}
|
|
77
|
+
function decodeWakeRecord(row) {
|
|
78
|
+
return row.kind === 'wake' && row.outcome === 'unknown' && row.unknownSource === undefined
|
|
79
|
+
? { ...row, unknownSource: row.signature === 'worker_interrupted_during_dispatch' ? 'interrupted' : 'transport' } : row;
|
|
80
|
+
}
|
|
81
|
+
/** Keep identity and native receipt, never a completed attempt's lease. */
|
|
82
|
+
function finishWake(current, outcome, details = {}) {
|
|
83
|
+
const { expiresAt: _expiresAt, ownerPid: _ownerPid, unknownSource: _unknownSource, ...rest } = current;
|
|
84
|
+
return { ...rest, ...details, kind: 'wake', outcome,
|
|
85
|
+
...(outcome === 'unknown' ? { unknownSource: details.unknownSource ?? 'transport' } : {}) };
|
|
101
86
|
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
if (e.code === 'ENOENT')
|
|
104
|
-
return [];
|
|
105
|
-
throw e;
|
|
106
|
-
} }
|
|
107
87
|
function processAlive(pid) { if (pid === undefined)
|
|
108
88
|
return undefined; try {
|
|
109
89
|
process.kill(pid, 0);
|
|
@@ -112,14 +92,11 @@ function processAlive(pid) { if (pid === undefined)
|
|
|
112
92
|
catch (error) {
|
|
113
93
|
return error.code === 'ESRCH' ? false : true;
|
|
114
94
|
} }
|
|
115
|
-
function writeClaims(file, rows) { return write(file, rows); }
|
|
116
95
|
export class FileHostLedgerPort {
|
|
117
96
|
root;
|
|
118
|
-
claims;
|
|
119
97
|
clock;
|
|
120
98
|
constructor(o = {}) {
|
|
121
|
-
this.root =
|
|
122
|
-
this.claims = canonRoot(o.claimsPath ?? path.join(this.root, 'wake-claims.ndjsonl'));
|
|
99
|
+
this.root = canonicalPathSync(o.rootPath ?? hostLedgerRoot());
|
|
123
100
|
this.clock = o.now ?? Date.now;
|
|
124
101
|
}
|
|
125
102
|
file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
|
|
@@ -145,7 +122,7 @@ export class FileHostLedgerPort {
|
|
|
145
122
|
if (typeof at === 'number') {
|
|
146
123
|
if (at > now)
|
|
147
124
|
next = Math.min(next, at);
|
|
148
|
-
if (at + RETENTION + 1 > now && !(
|
|
125
|
+
if (at + RETENTION + 1 > now && !durableWake(row))
|
|
149
126
|
next = Math.min(next, at + RETENTION + 1);
|
|
150
127
|
}
|
|
151
128
|
if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
|
|
@@ -153,11 +130,15 @@ export class FileHostLedgerPort {
|
|
|
153
130
|
}
|
|
154
131
|
return next;
|
|
155
132
|
}
|
|
133
|
+
/** The ownership claim critical section: the same lock as claimPresence and finalize. */
|
|
134
|
+
withClaimLock(fn, signal) {
|
|
135
|
+
return withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, fn);
|
|
136
|
+
}
|
|
156
137
|
async claimPresence(i, signal) {
|
|
157
|
-
const location = await
|
|
138
|
+
const location = await canonicalPath(i.location);
|
|
158
139
|
const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
|
|
159
140
|
try {
|
|
160
|
-
return await
|
|
141
|
+
return await this.withClaimLock(async () => {
|
|
161
142
|
const file = this.file('presence');
|
|
162
143
|
// The authoritative read, ownership check, and write share the presence file lock:
|
|
163
144
|
// a concurrent ensurePresence/removePresence between them must never be overwritten
|
|
@@ -170,28 +151,47 @@ export class FileHostLedgerPort {
|
|
|
170
151
|
await write(file, [...rows, record], signal);
|
|
171
152
|
return { status: 'acquired', record };
|
|
172
153
|
});
|
|
173
|
-
});
|
|
154
|
+
}, signal);
|
|
174
155
|
}
|
|
175
156
|
catch (error) {
|
|
176
157
|
return { status: 'degraded', record, error };
|
|
177
158
|
}
|
|
178
159
|
}
|
|
179
160
|
async ensurePresence(i) {
|
|
180
|
-
const record = { ...i, location: await
|
|
161
|
+
const record = { ...i, location: await canonicalPath(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
|
|
181
162
|
try {
|
|
182
|
-
await withFileLock(this.file('presence') + '.lock', LOCK, async () =>
|
|
163
|
+
await withFileLock(this.file('presence') + '.lock', LOCK, async () => {
|
|
164
|
+
const rows = await read(this.file('presence'), this.clock(), true);
|
|
165
|
+
const current = rows.findLast((row) => k(row) === k(record));
|
|
166
|
+
const sameEpoch = current?.epoch === record.epoch;
|
|
167
|
+
const cancelledThrough = sameEpoch ? Math.max(current?.cancelledThrough ?? -1, record.cancelledThrough ?? -1) : record.cancelledThrough ?? -1;
|
|
168
|
+
await write(this.file('presence'), [...rows.filter((row) => k(row) !== k(record)), { ...record, ...(cancelledThrough < 0 ? {} : { cancelledThrough }) }]);
|
|
169
|
+
});
|
|
183
170
|
return { status: 'ensured', record };
|
|
184
171
|
}
|
|
185
172
|
catch (error) {
|
|
186
173
|
return { status: 'degraded', record, error };
|
|
187
174
|
}
|
|
188
175
|
}
|
|
176
|
+
/** Local batch suppression is monotonic only for the captured host generation. */
|
|
177
|
+
async suppressPresence(i, through) {
|
|
178
|
+
const record = { ...i, location: await canonicalPath(i.location) };
|
|
179
|
+
const file = this.file('presence');
|
|
180
|
+
return withFileLock(file + '.lock', LOCK, async () => {
|
|
181
|
+
const rows = await read(file, this.clock(), true);
|
|
182
|
+
const current = rows.findLast((row) => k(row) === k(record) && row.epoch === record.epoch);
|
|
183
|
+
if (!current)
|
|
184
|
+
return false;
|
|
185
|
+
await write(file, rows.map((row) => row === current ? { ...row, cancelledThrough: Math.max(row.cancelledThrough ?? -1, through) } : row));
|
|
186
|
+
return true;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
189
|
async removePresence(i) {
|
|
190
|
-
const record = { ...i, location: await
|
|
190
|
+
const record = { ...i, location: await canonicalPath(i.location) };
|
|
191
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
192
|
}
|
|
193
193
|
async removePresenceIfUnchanged(i) {
|
|
194
|
-
const record = { ...i, location: await
|
|
194
|
+
const record = { ...i, location: await canonicalPath(i.location) };
|
|
195
195
|
return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
|
|
196
196
|
const file = this.file('presence');
|
|
197
197
|
const rows = await read(file, this.clock(), true);
|
|
@@ -206,43 +206,127 @@ export class FileHostLedgerPort {
|
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
208
|
async listPresence(i = {}) {
|
|
209
|
-
const location = i.location === undefined ? undefined : await
|
|
209
|
+
const location = i.location === undefined ? undefined : await canonicalPath(i.location);
|
|
210
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);
|
|
211
211
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
212
|
+
/** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
|
|
213
|
+
async claimWakeAttempt(i) {
|
|
214
|
+
const location = await canonicalPath(i.attention.squarePath);
|
|
215
|
+
const activity = formatActivityId(i.attention.actIndex);
|
|
216
|
+
const at = i.now ?? this.clock();
|
|
217
|
+
const file = this.file('evidence');
|
|
218
|
+
try {
|
|
219
|
+
return await withFileLock(file + '.lock', LOCK, async () => {
|
|
220
|
+
const all = await read(file, at, true);
|
|
221
|
+
const attention = all.filter((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(i.attention.recipient) && row.activity === activity);
|
|
222
|
+
const terminal = attention.findLast((row) => row.outcome === 'accepted') ?? attention.findLast((row) => row.outcome === 'unknown');
|
|
223
|
+
if (terminal)
|
|
224
|
+
return { status: 'terminal', record: terminal };
|
|
225
|
+
const interrupted = attention.filter((row) => row.outcome === 'dispatching');
|
|
226
|
+
const busy = interrupted.find((row) => (row.expiresAt ?? 0) > at && processAlive(row.ownerPid) !== false);
|
|
227
|
+
if (busy)
|
|
228
|
+
return { status: 'busy', record: busy };
|
|
229
|
+
const nextNumber = (session) => attention.filter((row) => row.session === session).reduce((highest, row) => Math.max(highest, row.attemptN ?? 0), 0) + 1;
|
|
230
|
+
// An abandoned attempt may already have been sent, so it blocks every route.
|
|
231
|
+
if (interrupted.length) {
|
|
232
|
+
const recovered = interrupted.map((row) => ({ ...finishWake(row, 'unknown', {
|
|
233
|
+
unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch',
|
|
234
|
+
message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
|
|
235
|
+
}), routeKind: row.routeKind ?? i.routeKind, attemptN: row.attemptN ?? nextNumber(row.session), v: 1 }));
|
|
236
|
+
const replacements = new Map(interrupted.map((row, index) => [row, recovered[index]]));
|
|
237
|
+
await write(file, all.map((row) => replacements.get(row) ?? row));
|
|
238
|
+
return { status: 'terminal', record: recovered[0] };
|
|
239
|
+
}
|
|
240
|
+
const attemptN = nextNumber(i.session), claimToken = `${process.pid}-${at}-${randomUUID()}`;
|
|
241
|
+
await write(file, [...all, { v: 1, at, location, participant: i.attention.recipient, session: i.session, activity,
|
|
242
|
+
kind: 'wake', outcome: 'dispatching', routeKind: i.routeKind, attemptN, claimToken, ownerPid: process.pid, expiresAt: at + i.leaseMs }]);
|
|
243
|
+
return { status: 'acquired', claimToken, attemptN };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return { status: 'degraded', error };
|
|
248
|
+
}
|
|
223
249
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
250
|
+
/** One locked, token-fenced replacement; each operation owns its state condition. */
|
|
251
|
+
async updateWake(input, update, at = this.clock()) {
|
|
252
|
+
if (!input.claimToken)
|
|
253
|
+
return false;
|
|
254
|
+
const location = await canonicalPath(input.location), file = this.file('evidence');
|
|
255
|
+
return withFileLock(file + '.lock', LOCK, async () => {
|
|
256
|
+
const all = await read(file, at, true);
|
|
257
|
+
const current = all.findLast((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(input.participant) && row.activity === input.activity && row.session === input.session && row.claimToken === input.claimToken);
|
|
258
|
+
const next = current && update(current);
|
|
259
|
+
if (!next)
|
|
260
|
+
return false;
|
|
261
|
+
if (next !== current)
|
|
262
|
+
await write(file, all.map((row) => row === current ? { ...next, v: 1 } : row));
|
|
263
|
+
return true;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async listWakeAttempts(i = {}) {
|
|
267
|
+
const attention = i.attention;
|
|
268
|
+
return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canonicalPath(attention.squarePath), participant: attention.recipient, activity: formatActivityId(attention.actIndex) }), session: i.session, now: i.now });
|
|
269
|
+
}
|
|
270
|
+
async prepareNativeWake(i) {
|
|
271
|
+
return this.updateWake(i, (current) => current.outcome === 'dispatching'
|
|
272
|
+
? { ...current, nativeDelivery: i.nativeDelivery, routeKind: i.routeKind, attemptN: i.attemptN } : undefined);
|
|
273
|
+
}
|
|
274
|
+
async confirmWakeAdmission(i) {
|
|
275
|
+
return this.updateWake(i, (current) => {
|
|
276
|
+
if (!current.nativeDelivery || !['dispatching', 'unknown', 'accepted'].includes(current.outcome))
|
|
277
|
+
return undefined;
|
|
278
|
+
return current.outcome === 'accepted' ? current : finishWake(current, 'accepted', { signature: 'native_queue_confirmed', message: undefined, diagnostic: undefined });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async claimEvidence(i) { if (i.kind === 'wake')
|
|
282
|
+
return { status: 'degraded', error: new Error('wake attempts are claimed with claimWakeAttempt') }; const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, ...claim } = i, r = { ...claim, location: await canonicalPath(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
|
|
233
283
|
return await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), matching = all.filter(x => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session), old = matching.findLast(x => x.outcome === 'dispatching'); if (old !== undefined && typeof old.expiresAt === 'number' && old.expiresAt > at)
|
|
234
|
-
return { status: 'busy', record: old };
|
|
235
|
-
return { status: 'delivered', record: matching.findLast(x => x.outcome === '
|
|
284
|
+
return { status: 'busy', record: old }; if (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented'))
|
|
285
|
+
return { status: 'delivered', record: matching.findLast(x => x.outcome === 'presented') }; const claimToken = `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
|
|
236
286
|
}
|
|
237
287
|
catch (error) {
|
|
238
288
|
return { status: 'degraded', error };
|
|
239
289
|
} }
|
|
240
|
-
async releaseEvidence(i) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
290
|
+
async releaseEvidence(i) {
|
|
291
|
+
if (i.kind === 'wake') {
|
|
292
|
+
const { signature, message, diagnostic, routeKind, attemptN } = i;
|
|
293
|
+
const details = Object.fromEntries(Object.entries({ signature, message, diagnostic, routeKind, attemptN }).filter(([, value]) => value !== undefined));
|
|
294
|
+
await this.updateWake(i, (current) => current.outcome === 'dispatching'
|
|
295
|
+
? finishWake(current, 'released', { ...details, at: i.now ?? this.clock() }) : undefined, i.now);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const f = this.file('evidence'), location = await canonicalPath(i.location), at = i.now ?? this.clock();
|
|
299
|
+
await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
|
|
300
|
+
return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; delete tombstone.ownerPid; await write(f, [...all.filter(x => x !== current), tombstone]); });
|
|
301
|
+
}
|
|
302
|
+
/** Token-fenced terminal write. A token may only finish the attempt it claimed. */
|
|
303
|
+
async appendEvidence(i) {
|
|
304
|
+
const f = this.file('evidence'), r = { ...i, location: await canonicalPath(i.location), at: i.at ?? this.clock(), v: 1 };
|
|
305
|
+
const token = i.claimToken;
|
|
306
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
307
|
+
return;
|
|
308
|
+
if (i.kind === 'wake') {
|
|
309
|
+
if (r.outcome !== 'accepted' && r.outcome !== 'failed' && r.outcome !== 'unknown')
|
|
310
|
+
return;
|
|
311
|
+
const outcome = r.outcome;
|
|
312
|
+
await this.updateWake(r, (current) => {
|
|
313
|
+
if (current.outcome !== 'dispatching' && !(current.outcome === 'unknown' && current.unknownSource === 'interrupted' && outcome === 'accepted'))
|
|
314
|
+
return undefined;
|
|
315
|
+
return finishWake(current, outcome, { at: r.at, routeKind: r.routeKind, attemptN: r.attemptN, signature: r.signature, message: r.message, diagnostic: r.diagnostic });
|
|
316
|
+
}, r.at);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await withFileLock(f + '.lock', LOCK, async () => {
|
|
320
|
+
const all = await read(f, r.at, true);
|
|
321
|
+
const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session;
|
|
322
|
+
const dispatching = all.findLast((x) => same(x) && x.outcome === 'dispatching');
|
|
323
|
+
const source = dispatching?.claimToken === token ? dispatching : undefined;
|
|
324
|
+
if (source === undefined)
|
|
325
|
+
return;
|
|
326
|
+
await write(f, [...all.filter((x) => x !== source && !(same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released')), { ...r, ...(source.nativeDelivery === undefined ? {} : { nativeDelivery: source.nativeDelivery }) }]);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canonicalPath(i.location); return (await read(this.file('evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
|
|
330
|
+
async gcEvidence(i) { const f = this.file('evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || durableWake(r) && pending.has(r.activity)))); }
|
|
247
331
|
}
|
|
248
332
|
export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }
|