agents-can-communicate 0.1.18 → 0.3.0
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/README.md +87 -69
- package/SECURITY.md +31 -0
- package/bin/acc-bootstrap.mjs +56 -0
- package/bin/acc-claude-channel.mjs +177 -0
- package/bin/acc-hook.mjs +94 -12
- package/bin/acc-mcp.mjs +6 -2
- package/bin/acc.mjs +13 -3
- package/docs/ADAPTER_AUTHORING.md +204 -0
- package/docs/ARCHITECTURE.md +131 -0
- package/docs/CAPABILITIES.md +117 -214
- package/docs/CLI.md +164 -0
- package/docs/CONCEPTS.md +134 -0
- package/docs/CONFIGURATION.md +147 -0
- package/docs/DESIGN_DECISIONS.md +89 -0
- package/docs/GETTING_STARTED.md +145 -0
- package/docs/GLOSSARY.md +26 -0
- package/docs/HOW_IT_WORKS.md +277 -0
- package/docs/MCP.md +94 -0
- package/docs/PROTOCOL.md +200 -0
- package/docs/RELEASING.md +115 -0
- package/docs/SECURITY_MODEL.md +131 -0
- package/docs/TROUBLESHOOTING.md +108 -0
- package/docs/WHY_ACC.md +61 -0
- package/docs/index.md +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +228 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +269 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.258.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.260.json +23 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +45 -5
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +377 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +27 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +229 -0
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +150 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +199 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-remote-workspace.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +11 -2
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +54 -12
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +121 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +151 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +192 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +68 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell-0.57.0.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart-0.57.0.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +293 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +31 -13
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +2 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +11 -9
- package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +22 -22
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +7 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +52 -18
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +36 -17
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +9 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +7 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +76 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +202 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +28 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +12 -31
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +70 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -5
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +111 -12
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +100 -121
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +131 -0
- package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +91 -107
- package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -3
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +21 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +22 -20
- package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -294
- package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +131 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +4 -2
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +90 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +190 -105
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +70 -10
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +144 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +89 -5
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +10 -2
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +161 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +112 -12
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +54 -2
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +210 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +102 -72
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +54 -97
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +17 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +64 -90
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +68 -26
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +113 -35
- package/package.json +20 -1
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +0 -41
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
|
@@ -1,55 +1,28 @@
|
|
|
1
1
|
import { AccError, EXIT } from "./errors.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// recipient's receipt can only move forwards, and never rewrites another
|
|
6
|
-
// recipient's state.
|
|
7
|
-
export const DELIVERY_STATES = Object.freeze(
|
|
8
|
-
["recorded", "queued", "injected", "seen", "acknowledged", "failed"]);
|
|
3
|
+
export const RECEIPT_STATES = Object.freeze(
|
|
4
|
+
["queued", "offered", "retrieved", "acknowledged"]);
|
|
9
5
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
seen: ["acknowledged"],
|
|
6
|
+
const RECEIPT_NEXT = Object.freeze({
|
|
7
|
+
queued: ["offered", "retrieved", "acknowledged"],
|
|
8
|
+
offered: ["retrieved", "acknowledged"],
|
|
9
|
+
retrieved: ["acknowledged"],
|
|
15
10
|
acknowledged: [],
|
|
16
|
-
failed: [],
|
|
17
11
|
});
|
|
18
12
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const TASK_NEXT = Object.freeze({
|
|
23
|
-
pending: ["in_progress", "blocked"],
|
|
24
|
-
in_progress: ["review", "done", "blocked"],
|
|
25
|
-
review: ["in_progress", "done", "blocked"],
|
|
26
|
-
blocked: ["pending", "in_progress"],
|
|
27
|
-
done: [],
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
function step(machine, allowed, label, current, next) {
|
|
31
|
-
if (!machine.includes(current)) {
|
|
32
|
-
throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(current)}`,
|
|
13
|
+
export function advanceReceipt(current, next) {
|
|
14
|
+
if (!RECEIPT_STATES.includes(current)) {
|
|
15
|
+
throw new AccError(EXIT.DATA, `unknown receipt state: ${String(current)}`,
|
|
33
16
|
{ current, next });
|
|
34
17
|
}
|
|
35
|
-
if (!
|
|
36
|
-
throw new AccError(EXIT.DATA, `unknown
|
|
18
|
+
if (!RECEIPT_STATES.includes(next)) {
|
|
19
|
+
throw new AccError(EXIT.DATA, `unknown receipt state: ${String(next)}`,
|
|
37
20
|
{ current, next });
|
|
38
21
|
}
|
|
39
|
-
// Re-declaring the current state is idempotent. Adapters retry at safe
|
|
40
|
-
// points, and a repeated receipt is not a protocol violation.
|
|
41
22
|
if (current === next) return next;
|
|
42
|
-
if (!
|
|
23
|
+
if (!RECEIPT_NEXT[current].includes(next)) {
|
|
43
24
|
throw new AccError(EXIT.CONFLICT,
|
|
44
|
-
`illegal
|
|
25
|
+
`illegal receipt transition from ${current} to ${next}`, { current, next });
|
|
45
26
|
}
|
|
46
27
|
return next;
|
|
47
28
|
}
|
|
48
|
-
|
|
49
|
-
export function advanceDelivery(current, next) {
|
|
50
|
-
return step(DELIVERY_STATES, DELIVERY_NEXT, "delivery", current, next);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function transitionTask(current, next) {
|
|
54
|
-
return step(TASK_STATES, TASK_NEXT, "task", current, next);
|
|
55
|
-
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { encode, publishAtomic } from "./atomic-json.mjs";
|
|
8
|
+
import { withRegularNoFollow } from "./safe-file.mjs";
|
|
9
|
+
|
|
10
|
+
const ACTIVE_JOURNAL_VERSION = 2;
|
|
11
|
+
const AUTHORITY_BYTES_LIMIT = 1024;
|
|
12
|
+
const GENERATION_WIDTH = 16;
|
|
13
|
+
const READ_ATTEMPTS = 4;
|
|
14
|
+
|
|
15
|
+
const data = (message, details) => {
|
|
16
|
+
throw new AccError(EXIT.DATA, message, details);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function activeJournalPath(paths, slot) {
|
|
20
|
+
if (slot !== 0 && slot !== 1) data("invalid active journal slot", { slot });
|
|
21
|
+
return path.join(paths.journal, `active.${slot}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exactKeys(value, expected, filePath) {
|
|
25
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)
|
|
26
|
+
|| Object.keys(value).sort().join("\0") !== [...expected].sort().join("\0")) {
|
|
27
|
+
data("active journal record is not closed", { filePath });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checksum(record) {
|
|
32
|
+
return createHash("sha256").update(JSON.stringify(record)).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function encodeAuthority(record) {
|
|
36
|
+
return encode({ activeJournalChecksum: checksum(record), record });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateActiveRecord(record, filePath) {
|
|
40
|
+
const base = ["activeJournalVersion", "generation", "previousChecksum", "state"];
|
|
41
|
+
if (record?.state === "idle") exactKeys(record, base, filePath);
|
|
42
|
+
else if (record?.state === "open") {
|
|
43
|
+
exactKeys(record, [...base, "transactionId", "firstSequence"], filePath);
|
|
44
|
+
} else data("invalid active journal state", { filePath, state: record?.state });
|
|
45
|
+
|
|
46
|
+
if (record.activeJournalVersion !== ACTIVE_JOURNAL_VERSION) {
|
|
47
|
+
data("unknown active journal version", { filePath,
|
|
48
|
+
activeJournalVersion: record.activeJournalVersion });
|
|
49
|
+
}
|
|
50
|
+
if (typeof record.generation !== "string"
|
|
51
|
+
|| !/^\d{16}$/.test(record.generation)) {
|
|
52
|
+
data("invalid active journal generation", { filePath, generation: record.generation });
|
|
53
|
+
}
|
|
54
|
+
const generation = BigInt(record.generation);
|
|
55
|
+
if (generation === 0n) {
|
|
56
|
+
if (record.state !== "idle" || record.previousChecksum !== null) {
|
|
57
|
+
data("invalid active journal genesis", { filePath });
|
|
58
|
+
}
|
|
59
|
+
} else if (typeof record.previousChecksum !== "string"
|
|
60
|
+
|| !/^[0-9a-f]{64}$/.test(record.previousChecksum)) {
|
|
61
|
+
data("invalid active journal previous checksum", { filePath });
|
|
62
|
+
}
|
|
63
|
+
if (record.state === "open") {
|
|
64
|
+
assertPortableId(record.transactionId, "transaction id");
|
|
65
|
+
if (typeof record.firstSequence !== "string"
|
|
66
|
+
|| !/^\d{16}$/.test(record.firstSequence)) {
|
|
67
|
+
data("invalid active journal first sequence", { filePath,
|
|
68
|
+
firstSequence: record.firstSequence });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return generation;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decodeAuthority(bytes, filePath, slot) {
|
|
75
|
+
let envelope;
|
|
76
|
+
try {
|
|
77
|
+
envelope = JSON.parse(bytes.toString("utf8"));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
data("invalid active journal authority", { filePath, cause: error.message });
|
|
80
|
+
}
|
|
81
|
+
exactKeys(envelope, ["activeJournalChecksum", "record"], filePath);
|
|
82
|
+
const generation = validateActiveRecord(envelope.record, filePath);
|
|
83
|
+
if (Number(generation % 2n) !== slot) {
|
|
84
|
+
data("active journal generation chain is invalid", { filePath,
|
|
85
|
+
generation: envelope.record.generation, slot });
|
|
86
|
+
}
|
|
87
|
+
if (typeof envelope.activeJournalChecksum !== "string"
|
|
88
|
+
|| envelope.activeJournalChecksum !== checksum(envelope.record)) {
|
|
89
|
+
data("active journal checksum mismatch", { filePath });
|
|
90
|
+
}
|
|
91
|
+
if (!bytes.equals(encodeAuthority(envelope.record))) {
|
|
92
|
+
data("active journal authority is not canonical", { filePath });
|
|
93
|
+
}
|
|
94
|
+
return { checksum: envelope.activeJournalChecksum, generation,
|
|
95
|
+
record: envelope.record, slot };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readSlotBytes(paths, root, slot, openFile) {
|
|
99
|
+
const filePath = activeJournalPath(paths, slot);
|
|
100
|
+
try {
|
|
101
|
+
return await withRegularNoFollow(filePath, root, constants.O_RDONLY,
|
|
102
|
+
async (handle, stat) => {
|
|
103
|
+
if (stat.size < 1 || stat.size > AUTHORITY_BYTES_LIMIT) {
|
|
104
|
+
data("invalid active journal authority size", { filePath, size: stat.size });
|
|
105
|
+
}
|
|
106
|
+
return handle.readFile();
|
|
107
|
+
}, openFile);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code === "ENOENT") return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sameBytes(left, right) {
|
|
115
|
+
if (left === null || right === null) return left === right;
|
|
116
|
+
return left.equals(right);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Slot zero is read twice. Because writers alternate slots and generations
|
|
120
|
+
// never repeat, equal reads prove that the pair existed at one instant even if
|
|
121
|
+
// another process published between descriptor opens.
|
|
122
|
+
async function readStableSlots(paths, root, openFile) {
|
|
123
|
+
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
|
|
124
|
+
const slot0 = await readSlotBytes(paths, root, 0, openFile);
|
|
125
|
+
const slot1 = await readSlotBytes(paths, root, 1, openFile);
|
|
126
|
+
const slot0Confirmation = await readSlotBytes(paths, root, 0, openFile);
|
|
127
|
+
if (sameBytes(slot0, slot0Confirmation)) return [slot0, slot1];
|
|
128
|
+
}
|
|
129
|
+
throw new AccError(EXIT.CONFLICT, "active journal changed while being read", {});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateTransition(current, previous, filePath) {
|
|
133
|
+
const expected = previous.state === "idle" ? "open" : "idle";
|
|
134
|
+
if (current.state !== expected) {
|
|
135
|
+
data("invalid active journal transition", { filePath,
|
|
136
|
+
previous: previous.state, current: current.state });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compareGenerations(left, right) {
|
|
141
|
+
if (left.generation < right.generation) return -1;
|
|
142
|
+
if (left.generation > right.generation) return 1;
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function selectAuthority(paths, bytes) {
|
|
147
|
+
// Decode every present slot before selection. Falling back from a corrupt
|
|
148
|
+
// latest slot to an older valid peer would turn corruption into rollback.
|
|
149
|
+
const found = bytes.map((value, slot) => value === null
|
|
150
|
+
? null
|
|
151
|
+
: decodeAuthority(value, activeJournalPath(paths, slot), slot)).filter(Boolean);
|
|
152
|
+
if (found.length === 0) return null;
|
|
153
|
+
if (found.length === 1) {
|
|
154
|
+
const [only] = found;
|
|
155
|
+
if (only.slot === 0 && only.generation === 0n) return only;
|
|
156
|
+
data("active journal authority peer is missing", { slot: only.slot,
|
|
157
|
+
generation: only.record.generation });
|
|
158
|
+
}
|
|
159
|
+
found.sort(compareGenerations);
|
|
160
|
+
const [previous, current] = found;
|
|
161
|
+
if (current.generation !== previous.generation + 1n) {
|
|
162
|
+
data("active journal generation chain is invalid", {
|
|
163
|
+
previous: previous.record.generation, current: current.record.generation });
|
|
164
|
+
}
|
|
165
|
+
if (current.record.previousChecksum !== previous.checksum) {
|
|
166
|
+
data("active journal checksum chain is invalid", { slot: current.slot });
|
|
167
|
+
}
|
|
168
|
+
validateTransition(current.record, previous.record,
|
|
169
|
+
activeJournalPath(paths, current.slot));
|
|
170
|
+
return current;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readCurrent(paths, root, openFile) {
|
|
174
|
+
return selectAuthority(paths, await readStableSlots(paths, root, openFile));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const genesis = () => ({ activeJournalVersion: ACTIVE_JOURNAL_VERSION,
|
|
178
|
+
generation: "0".repeat(GENERATION_WIDTH), previousChecksum: null, state: "idle" });
|
|
179
|
+
|
|
180
|
+
export async function initialiseActiveJournal(paths, options) {
|
|
181
|
+
const current = await readCurrent(paths, options.root);
|
|
182
|
+
if (current !== null) return current.record;
|
|
183
|
+
try {
|
|
184
|
+
await publishAtomic(activeJournalPath(paths, 0), encodeAuthority(genesis()), options);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code !== EXIT.CONFLICT) throw error;
|
|
187
|
+
}
|
|
188
|
+
return readActiveJournal(paths, options.root);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readActiveJournal(paths, root, openFile) {
|
|
192
|
+
const current = await readCurrent(paths, root, openFile);
|
|
193
|
+
if (current === null) data("active journal has no authority", { journal: paths.journal });
|
|
194
|
+
return current.record;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function nextGeneration(current) {
|
|
198
|
+
const next = current.generation + 1n;
|
|
199
|
+
const generation = next.toString().padStart(GENERATION_WIDTH, "0");
|
|
200
|
+
if (generation.length !== GENERATION_WIDTH) {
|
|
201
|
+
data("active journal generation is exhausted", { current: current.record.generation });
|
|
202
|
+
}
|
|
203
|
+
return generation;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function appendTransition(paths, options, fields, expected) {
|
|
207
|
+
const current = await readCurrent(paths, options.root, options.openFile);
|
|
208
|
+
if (current === null || !expected(current.record)) {
|
|
209
|
+
throw new AccError(EXIT.CONFLICT, "active journal transition conflicts",
|
|
210
|
+
{ current: current?.record ?? null, next: fields });
|
|
211
|
+
}
|
|
212
|
+
const record = { activeJournalVersion: ACTIVE_JOURNAL_VERSION,
|
|
213
|
+
generation: nextGeneration(current), previousChecksum: current.checksum, ...fields };
|
|
214
|
+
validateActiveRecord(record, activeJournalPath(paths, 1 - current.slot));
|
|
215
|
+
await publishAtomic(activeJournalPath(paths, 1 - current.slot), encodeAuthority(record),
|
|
216
|
+
{ ...options, tmpDir: options.tmpDir ?? paths.tmp ?? path.join(options.root, "tmp"),
|
|
217
|
+
replace: true });
|
|
218
|
+
return record;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function activateJournal(paths, options, entry) {
|
|
222
|
+
return appendTransition(paths, options, { state: "open",
|
|
223
|
+
transactionId: entry.transactionId, firstSequence: entry.firstSequence },
|
|
224
|
+
current => current.state === "idle");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function idleJournal(paths, options, transactionId) {
|
|
228
|
+
return appendTransition(paths, options, { state: "idle" },
|
|
229
|
+
current => current.state === "open" && current.transactionId === transactionId);
|
|
230
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { link, open, readdir, rename
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, open, readdir, rename } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
@@ -16,14 +16,6 @@ async function syncDirectory(directory) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function unlinkIfPresent(filePath) {
|
|
20
|
-
try {
|
|
21
|
-
await unlink(filePath);
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if (error.code !== "ENOENT") throw error;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
19
|
export function encode(value) {
|
|
28
20
|
const serialised = JSON.stringify(value, null, 2);
|
|
29
21
|
if (serialised === undefined) {
|
|
@@ -41,6 +33,23 @@ async function bytesIfPresent(filePath, root, openFile) {
|
|
|
41
33
|
}
|
|
42
34
|
}
|
|
43
35
|
|
|
36
|
+
function retainedStage(destination, root, tmpDir) {
|
|
37
|
+
const identity = createHash("sha256")
|
|
38
|
+
.update(path.relative(root, destination))
|
|
39
|
+
.digest("hex");
|
|
40
|
+
return path.join(tmpDir, `${identity}.published`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function replaceHandleBytes(handle, bytes) {
|
|
44
|
+
await handle.truncate(0);
|
|
45
|
+
let offset = 0;
|
|
46
|
+
while (offset < bytes.length) {
|
|
47
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
|
|
48
|
+
offset += bytesWritten;
|
|
49
|
+
}
|
|
50
|
+
await handle.sync();
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
/**
|
|
45
54
|
* Publish bytes atomically.
|
|
46
55
|
*
|
|
@@ -59,31 +68,64 @@ export async function publishAtomic(destination, bytes, { root, tmpDir, replace
|
|
|
59
68
|
ensureManagedDirectory(root, tmpDir),
|
|
60
69
|
ensureManagedDirectory(root, destinationDir),
|
|
61
70
|
]);
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
if (!replace) {
|
|
72
|
+
const existing = await bytesIfPresent(destination, root);
|
|
73
|
+
if (existing !== null) {
|
|
74
|
+
if (existing.equals(bytes)) return "already_published";
|
|
75
|
+
throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
|
|
76
|
+
{ destination });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stage = retainedStage(destination, root, tmpDir);
|
|
81
|
+
const temporary = `${stage}.${process.pid}.${randomUUID()}.tmp`;
|
|
82
|
+
let handle = await open(temporary, "wx");
|
|
83
|
+
let stageAcceptedBytes = false;
|
|
64
84
|
try {
|
|
65
85
|
await handle.writeFile(bytes);
|
|
66
86
|
await handle.sync();
|
|
67
|
-
} finally {
|
|
68
|
-
await handle.close();
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
87
|
if (replace) {
|
|
88
|
+
await handle.close();
|
|
89
|
+
handle = null;
|
|
72
90
|
await rename(temporary, destination);
|
|
73
91
|
await syncDirectory(destinationDir);
|
|
74
92
|
return "published";
|
|
75
93
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await link(temporary, destination);
|
|
97
|
+
stageAcceptedBytes = true;
|
|
98
|
+
await syncDirectory(destinationDir);
|
|
99
|
+
return "published";
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code !== "EEXIST") throw error;
|
|
102
|
+
const existing = await bytesIfPresent(destination, root);
|
|
103
|
+
if (existing !== null && existing.equals(bytes)) {
|
|
104
|
+
stageAcceptedBytes = true;
|
|
105
|
+
return "already_published";
|
|
106
|
+
}
|
|
107
|
+
if (existing !== null) {
|
|
108
|
+
// This caller lost a different-payload race. Keep no rejected bytes:
|
|
109
|
+
// rewrite its still-open private inode with the accepted destination
|
|
110
|
+
// before consolidating every contender onto one retained stage name.
|
|
111
|
+
await replaceHandleBytes(handle, existing);
|
|
112
|
+
stageAcceptedBytes = true;
|
|
113
|
+
}
|
|
114
|
+
throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
|
|
115
|
+
{ destination });
|
|
116
|
+
}
|
|
85
117
|
} finally {
|
|
86
|
-
await
|
|
118
|
+
await handle?.close();
|
|
119
|
+
if (stageAcceptedBytes) {
|
|
120
|
+
await assertManagedDirectory(root, tmpDir);
|
|
121
|
+
await rename(temporary, stage);
|
|
122
|
+
await syncDirectory(tmpDir);
|
|
123
|
+
} else {
|
|
124
|
+
// A crash/error before immutable acceptance keeps its unique partial.
|
|
125
|
+
// Retention avoids the unsafe parent-check/unlink pathname window, while
|
|
126
|
+
// the deterministic accepted stage bounds all ordinary retries.
|
|
127
|
+
await retainFile(temporary, { root });
|
|
128
|
+
}
|
|
87
129
|
}
|
|
88
130
|
}
|
|
89
131
|
|
|
@@ -132,6 +174,13 @@ export async function listJsonFiles(dirPath, options) {
|
|
|
132
174
|
.sort((left, right) => left.localeCompare(right));
|
|
133
175
|
}
|
|
134
176
|
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
// Node exposes unlink only by pathname: directory handles cannot be passed to
|
|
178
|
+
// unlinkat, so checking a parent and then unlinking still lets an adversary
|
|
179
|
+
// replace that parent in between. Retention is the safe primitive. Callers
|
|
180
|
+
// publish an append-only logical marker after this validation and never unlink
|
|
181
|
+
// the retained path. The callback is the deterministic race seam.
|
|
182
|
+
export async function retainFile(filePath, { root, afterValidation } = {}) {
|
|
183
|
+
await assertManagedDirectory(root, path.dirname(filePath));
|
|
184
|
+
await afterValidation?.();
|
|
185
|
+
return "retained";
|
|
137
186
|
}
|
|
@@ -4,7 +4,7 @@ import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protoc
|
|
|
4
4
|
|
|
5
5
|
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
6
|
|
|
7
|
-
export const STORE_VERSION =
|
|
7
|
+
export const STORE_VERSION = 6;
|
|
8
8
|
|
|
9
9
|
export function identityPath(paths) {
|
|
10
10
|
return path.join(paths.root, "protocol.json");
|
|
@@ -1,21 +1,51 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
|
-
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { activateJournal, idleJournal, readActiveJournal } from "./active-journal.mjs";
|
|
6
|
+
import { encode, publishAtomic, readJsonIfPresent, retainFile }
|
|
6
7
|
from "./atomic-json.mjs";
|
|
8
|
+
import { completeJournal } from "./retention.mjs";
|
|
7
9
|
|
|
8
|
-
export const JOURNAL_VERSION =
|
|
10
|
+
export const JOURNAL_VERSION = 2;
|
|
9
11
|
|
|
10
|
-
// A journal entry is
|
|
11
|
-
// and every byte is known.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// A journal entry is prepared only after the transaction callback has
|
|
13
|
+
// succeeded and every byte is known. The subsequent synced active-log record
|
|
14
|
+
// is the commit point: an orphan prepared journal is not a decided
|
|
15
|
+
// transaction. Roll-forward is idempotent because identical immutable bytes
|
|
16
|
+
// are accepted and materialised state carries generations.
|
|
15
17
|
export function journalPath(paths, transactionId) {
|
|
18
|
+
assertPortableId(transactionId, "transaction id");
|
|
16
19
|
return path.join(paths.journal, `${transactionId}.json`);
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
function publicationDestination(root, publicationPath) {
|
|
23
|
+
if (typeof publicationPath !== "string" || publicationPath === ""
|
|
24
|
+
|| path.isAbsolute(publicationPath) || path.win32.isAbsolute(publicationPath)) {
|
|
25
|
+
throw new AccError(EXIT.DATA, "journal publication path must be relative",
|
|
26
|
+
{ publicationPath });
|
|
27
|
+
}
|
|
28
|
+
const segments = publicationPath.split(/[\\/]/);
|
|
29
|
+
if (segments.some(segment => segment === "" || segment === "." || segment === "..")) {
|
|
30
|
+
throw new AccError(EXIT.DATA, "journal publication path is not managed",
|
|
31
|
+
{ publicationPath });
|
|
32
|
+
}
|
|
33
|
+
const durablePublication = segments[0] === "events" || segments[0] === "state";
|
|
34
|
+
const stateRetention = segments[0] === "retained" && segments[1] === "state";
|
|
35
|
+
if (!(durablePublication || stateRetention)) {
|
|
36
|
+
throw new AccError(EXIT.DATA, "journal publication path is not managed",
|
|
37
|
+
{ publicationPath });
|
|
38
|
+
}
|
|
39
|
+
const destination = path.resolve(root, ...segments);
|
|
40
|
+
const relative = path.relative(root, destination);
|
|
41
|
+
if (relative === "" || path.isAbsolute(relative) || relative === ".."
|
|
42
|
+
|| relative.startsWith(`..${path.sep}`)) {
|
|
43
|
+
throw new AccError(EXIT.DATA, "journal publication path escapes the store root",
|
|
44
|
+
{ publicationPath, root });
|
|
45
|
+
}
|
|
46
|
+
return destination;
|
|
47
|
+
}
|
|
48
|
+
|
|
19
49
|
export function journalEntry(transactionId, firstSequence, publications, startedAt) {
|
|
20
50
|
return {
|
|
21
51
|
journalVersion: JOURNAL_VERSION,
|
|
@@ -24,41 +54,61 @@ export function journalEntry(transactionId, firstSequence, publications, started
|
|
|
24
54
|
startedAt,
|
|
25
55
|
publications: publications.map(item => ({
|
|
26
56
|
path: item.path,
|
|
27
|
-
|
|
28
|
-
// two deletions replays to the same end state rather than a partial one.
|
|
29
|
-
bytes: item.remove === true ? null : item.bytes.toString("base64"),
|
|
57
|
+
bytes: item.bytes.toString("base64"),
|
|
30
58
|
replace: item.replace === true,
|
|
31
|
-
|
|
59
|
+
retainedPath: item.retainedPath ?? null,
|
|
32
60
|
})),
|
|
33
61
|
};
|
|
34
62
|
}
|
|
35
63
|
|
|
36
64
|
export async function writeJournalEntry(paths, options, entry) {
|
|
37
65
|
await publishAtomic(journalPath(paths, entry.transactionId), encode(entry), options);
|
|
66
|
+
await options.failAt?.("after-journal-prepared");
|
|
67
|
+
await activateJournal(paths, options, entry);
|
|
38
68
|
return entry;
|
|
39
69
|
}
|
|
40
70
|
|
|
41
|
-
export async function retireJournalEntry(paths, transactionId) {
|
|
42
|
-
await
|
|
71
|
+
export async function retireJournalEntry(paths, options, transactionId) {
|
|
72
|
+
await retainFile(journalPath(paths, transactionId), { root: paths.root });
|
|
73
|
+
await completeJournal(paths, options, transactionId);
|
|
74
|
+
await options.failAt?.("before-journal-idle");
|
|
75
|
+
await idleJournal(paths, options, transactionId);
|
|
43
76
|
}
|
|
44
77
|
|
|
45
78
|
export async function readOpenJournals(paths, root) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
const active = await readActiveJournal(paths, root);
|
|
80
|
+
if (active.state === "idle") return [];
|
|
81
|
+
const file = journalPath(paths, active.transactionId);
|
|
82
|
+
const found = await readJsonIfPresent(file, root);
|
|
83
|
+
if (found === null) {
|
|
84
|
+
throw new AccError(EXIT.DATA, "active journal entry is missing", { file });
|
|
85
|
+
}
|
|
86
|
+
const entry = found.value;
|
|
87
|
+
if (entry?.journalVersion !== JOURNAL_VERSION) {
|
|
88
|
+
throw new AccError(EXIT.DATA, "unknown journal version", { file,
|
|
89
|
+
journalVersion: entry?.journalVersion });
|
|
90
|
+
}
|
|
91
|
+
if (path.basename(file, ".json") !== entry.transactionId
|
|
92
|
+
|| entry.transactionId !== active.transactionId
|
|
93
|
+
|| entry.firstSequence !== active.firstSequence) {
|
|
94
|
+
throw new AccError(EXIT.DATA, "journal entry does not match its active record", { file });
|
|
95
|
+
}
|
|
96
|
+
assertPortableId(entry.transactionId, "transaction id");
|
|
97
|
+
if (!Array.isArray(entry.publications)) {
|
|
98
|
+
throw new AccError(EXIT.DATA, "journal publications must be an array", { file });
|
|
99
|
+
}
|
|
100
|
+
for (const publication of entry.publications) {
|
|
101
|
+
publicationDestination(root, publication?.path);
|
|
102
|
+
if (publication?.retainedPath !== null) {
|
|
103
|
+
publicationDestination(root, publication?.retainedPath);
|
|
58
104
|
}
|
|
59
|
-
entries.push(entry);
|
|
60
105
|
}
|
|
61
|
-
return
|
|
106
|
+
return [entry];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function readJournalCeiling(paths, root) {
|
|
110
|
+
const active = await readActiveJournal(paths, root);
|
|
111
|
+
return active.state === "open" ? active.firstSequence : null;
|
|
62
112
|
}
|
|
63
113
|
|
|
64
114
|
// Publishing every listed file and then retiring the entry. Already-published
|
|
@@ -67,12 +117,10 @@ export async function readOpenJournals(paths, root) {
|
|
|
67
117
|
export async function rollForward(paths, options, entry) {
|
|
68
118
|
const published = [];
|
|
69
119
|
for (const publication of entry.publications) {
|
|
70
|
-
const destination =
|
|
71
|
-
if (publication.
|
|
72
|
-
await
|
|
73
|
-
|
|
74
|
-
await options.failAt?.(`after:${publication.path}`);
|
|
75
|
-
continue;
|
|
120
|
+
const destination = publicationDestination(options.root, publication.path);
|
|
121
|
+
if (publication.retainedPath !== null) {
|
|
122
|
+
await retainFile(publicationDestination(options.root, publication.retainedPath),
|
|
123
|
+
{ root: options.root });
|
|
76
124
|
}
|
|
77
125
|
const bytes = Buffer.from(publication.bytes, "base64");
|
|
78
126
|
const outcome = await publishAtomic(destination, bytes,
|
|
@@ -82,6 +130,6 @@ export async function rollForward(paths, options, entry) {
|
|
|
82
130
|
// same decided transaction.
|
|
83
131
|
await options.failAt?.(`after:${publication.path}`);
|
|
84
132
|
}
|
|
85
|
-
await retireJournalEntry(paths, entry.transactionId);
|
|
133
|
+
await retireJournalEntry(paths, options, entry.transactionId);
|
|
86
134
|
return published;
|
|
87
135
|
}
|