agents-can-communicate 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
|
+
import { readStoreIdentity } from "./identity.mjs";
|
|
7
|
+
import { readOpenJournals, rollForward } from "./journal.mjs";
|
|
8
|
+
import { assertEventBinding, assertStateBinding } from "./record-id.mjs";
|
|
9
|
+
import { storePaths } from "./store.mjs";
|
|
10
|
+
import { withWriterMutex } from "./writer-mutex.mjs";
|
|
11
|
+
|
|
12
|
+
/** @typedef {{ healthy: boolean, repaired: string[], blocked: string[],
|
|
13
|
+
* corrupt: string[] }} RepairReport */
|
|
14
|
+
|
|
15
|
+
const report = ({ repaired = [], blocked = [], corrupt = [] }) => ({
|
|
16
|
+
healthy: blocked.length === 0 && corrupt.length === 0,
|
|
17
|
+
repaired: [...repaired].sort(),
|
|
18
|
+
blocked: [...blocked].sort(),
|
|
19
|
+
corrupt: [...corrupt].sort(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function inspect(root) {
|
|
23
|
+
const paths = storePaths(root);
|
|
24
|
+
const blocked = [];
|
|
25
|
+
const corrupt = [];
|
|
26
|
+
let pending = [];
|
|
27
|
+
|
|
28
|
+
// Identity first. A store whose version or identity does not validate is
|
|
29
|
+
// never mutated, so an operator repairing the wrong directory fails closed
|
|
30
|
+
// instead of rewriting someone else's state.
|
|
31
|
+
let identity = null;
|
|
32
|
+
try {
|
|
33
|
+
identity = await readStoreIdentity(paths);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
blocked.push(path.join(root, "protocol.json"));
|
|
36
|
+
return { paths, identity: null, pending, blocked, corrupt, reason: error.message };
|
|
37
|
+
}
|
|
38
|
+
if (identity === null) blocked.push(path.join(root, "protocol.json"));
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
pending = await readOpenJournals(paths, root);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
corrupt.push(paths.journal);
|
|
44
|
+
return { paths, identity, pending: [], blocked, corrupt, reason: error.message };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const kind of (await listDirectoryEntries(paths.state, { root }))
|
|
48
|
+
.filter(entry => entry.isDirectory()).map(entry => entry.name)) {
|
|
49
|
+
for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
|
|
50
|
+
try {
|
|
51
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
52
|
+
const envelope = assertStateBinding(found.value, kind,
|
|
53
|
+
path.basename(filePath, ".json"), filePath);
|
|
54
|
+
validateRecord(envelope.kind, envelope.record);
|
|
55
|
+
} catch {
|
|
56
|
+
corrupt.push(filePath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let expected = 1;
|
|
62
|
+
for (const filePath of await listJsonFiles(paths.events, { root })) {
|
|
63
|
+
try {
|
|
64
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
65
|
+
const event = assertEventBinding(found.value, filePath);
|
|
66
|
+
validateRecord("event", event);
|
|
67
|
+
// A gap can only survive a completed roll-forward if a file was removed
|
|
68
|
+
// by hand, so it is corruption rather than a pending publication.
|
|
69
|
+
if (Number(event.sequence) !== expected && pending.length === 0) corrupt.push(filePath);
|
|
70
|
+
expected = Number(event.sequence) + 1;
|
|
71
|
+
} catch {
|
|
72
|
+
corrupt.push(filePath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { paths, identity, pending, blocked, corrupt, reason: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function diagnoseFilesystemStore({ root }) {
|
|
80
|
+
const state = await inspect(root);
|
|
81
|
+
return report({
|
|
82
|
+
blocked: state.blocked,
|
|
83
|
+
corrupt: state.corrupt,
|
|
84
|
+
repaired: state.pending.map(entry => entry.transactionId),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function repairFilesystemStore({ root, clock }) {
|
|
89
|
+
const state = await inspect(root);
|
|
90
|
+
if (state.blocked.length > 0 || state.corrupt.length > 0) {
|
|
91
|
+
// Fail closed. Completing a journal on top of state we cannot even read
|
|
92
|
+
// would turn an ambiguous store into a confidently wrong one.
|
|
93
|
+
return report({ blocked: state.blocked, corrupt: state.corrupt });
|
|
94
|
+
}
|
|
95
|
+
if (state.pending.length === 0) return report({});
|
|
96
|
+
|
|
97
|
+
const repaired = await withWriterMutex(state.paths, { root, tmpDir: state.paths.tmp, clock },
|
|
98
|
+
async () => {
|
|
99
|
+
const completed = [];
|
|
100
|
+
for (const entry of await readOpenJournals(state.paths, root)) {
|
|
101
|
+
await rollForward(state.paths, { root, tmpDir: state.paths.tmp, clock }, entry);
|
|
102
|
+
completed.push(entry.transactionId);
|
|
103
|
+
}
|
|
104
|
+
return completed;
|
|
105
|
+
});
|
|
106
|
+
return report({ repaired });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function assertRepairable(reportValue) {
|
|
110
|
+
if (!reportValue.healthy) {
|
|
111
|
+
throw new AccError(EXIT.DATA, "store state is ambiguous; repair is blocked", reportValue);
|
|
112
|
+
}
|
|
113
|
+
return reportValue;
|
|
114
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
5
|
+
|
|
6
|
+
// Ported from the reconciled prototype without semantic change. Every managed
|
|
7
|
+
// path is validated segment by segment against the canonical root, so a
|
|
8
|
+
// symlinked ancestor cannot redirect a read or a publication outside the store.
|
|
9
|
+
function invalidDirectory(message, directory, root, cause) {
|
|
10
|
+
return new AccError(EXIT.DATA, message, {
|
|
11
|
+
directory,
|
|
12
|
+
root,
|
|
13
|
+
...(cause === undefined ? {} : { cause }),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function relativeWithin(root, directory) {
|
|
18
|
+
const relative = path.relative(root, directory);
|
|
19
|
+
if (relative === "" || (!path.isAbsolute(relative)
|
|
20
|
+
&& relative !== ".." && !relative.startsWith(`..${path.sep}`))) return relative;
|
|
21
|
+
throw invalidDirectory("managed directory escapes the store root", directory, root);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function absolutePath(value, name, root = value) {
|
|
25
|
+
if (typeof value !== "string" || !path.isAbsolute(value)) {
|
|
26
|
+
throw invalidDirectory(`${name} must be absolute`, value, root);
|
|
27
|
+
}
|
|
28
|
+
return path.normalize(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function inspectRealDirectory(directory, root, create) {
|
|
32
|
+
let details;
|
|
33
|
+
try {
|
|
34
|
+
details = await lstat(directory);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code === "ENOENT" && !create) throw error;
|
|
37
|
+
if (error.code !== "ENOENT") {
|
|
38
|
+
throw invalidDirectory("cannot inspect managed directory", directory, root, error.message);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await mkdir(directory);
|
|
42
|
+
} catch (mkdirError) {
|
|
43
|
+
if (mkdirError.code !== "EEXIST") {
|
|
44
|
+
throw invalidDirectory("cannot create managed directory", directory, root,
|
|
45
|
+
mkdirError.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
details = await lstat(directory);
|
|
49
|
+
}
|
|
50
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
51
|
+
throw invalidDirectory("managed directory is not a real directory", directory, root);
|
|
52
|
+
}
|
|
53
|
+
return details;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
57
|
+
const root = absolutePath(rootPath, "managed root");
|
|
58
|
+
const directory = absolutePath(directoryPath, "managed directory", root);
|
|
59
|
+
const relative = relativeWithin(root, directory);
|
|
60
|
+
let details = await inspectRealDirectory(root, root, create);
|
|
61
|
+
const canonicalRoot = await realpath(root);
|
|
62
|
+
let current = root;
|
|
63
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
64
|
+
current = path.join(current, segment);
|
|
65
|
+
details = await inspectRealDirectory(current, root, create);
|
|
66
|
+
const expected = path.join(canonicalRoot, path.relative(root, current));
|
|
67
|
+
if (await realpath(current) !== expected) {
|
|
68
|
+
throw invalidDirectory("managed directory escapes the canonical store root", current, root);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { directory, stat: details };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function assertManagedDirectory(rootPath, directoryPath) {
|
|
75
|
+
return inspectManagedDirectory(rootPath, directoryPath, false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function ensureManagedDirectory(rootPath, directoryPath) {
|
|
79
|
+
return (await inspectManagedDirectory(rootPath, directoryPath, true)).directory;
|
|
80
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { assertManagedDirectory } from "./safe-directory.mjs";
|
|
8
|
+
|
|
9
|
+
function sameDirectory(left, right) {
|
|
10
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// O_NOFOLLOW plus a before/after identity check on the parent: the bytes
|
|
14
|
+
// returned come from the handle that was opened, not from whatever the path
|
|
15
|
+
// resolves to afterwards. The injected opener is the seam the crash-window and
|
|
16
|
+
// race tests use, and stays the final argument.
|
|
17
|
+
export async function readRegularNoFollow(filePath, root, openFile = open) {
|
|
18
|
+
const parent = path.dirname(filePath);
|
|
19
|
+
const before = await assertManagedDirectory(root, parent);
|
|
20
|
+
let handle;
|
|
21
|
+
try {
|
|
22
|
+
handle = await openFile(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === "ENOENT") throw error;
|
|
25
|
+
throw new AccError(EXIT.DATA, "cannot safely open regular file",
|
|
26
|
+
{ filePath, cause: error.message });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const stat = await handle.stat();
|
|
30
|
+
if (!stat.isFile()) {
|
|
31
|
+
throw new AccError(EXIT.DATA, "record is not a regular file", { filePath });
|
|
32
|
+
}
|
|
33
|
+
const after = await assertManagedDirectory(root, parent);
|
|
34
|
+
if (!sameDirectory(before.stat, after.stat)) {
|
|
35
|
+
throw new AccError(EXIT.DATA, "record parent directory changed while opening",
|
|
36
|
+
{ filePath, root });
|
|
37
|
+
}
|
|
38
|
+
return await handle.readFile();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof AccError || error.code === "ENOENT") throw error;
|
|
41
|
+
throw new AccError(EXIT.DATA, "cannot safely read regular file",
|
|
42
|
+
{ filePath, cause: error.message });
|
|
43
|
+
} finally {
|
|
44
|
+
await handle.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readJsonNoFollow(filePath, root, openFile = open) {
|
|
49
|
+
const bytes = await readRegularNoFollow(filePath, root, openFile);
|
|
50
|
+
try {
|
|
51
|
+
return { value: JSON.parse(bytes.toString("utf8")), bytes };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new AccError(EXIT.DATA, "invalid JSON record", { filePath, cause: error.message });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
|
|
5
|
+
|
|
6
|
+
import { encode, listDirectoryEntries, listJsonFiles, publishAtomic, readJsonIfPresent,
|
|
7
|
+
removeIfPresent } from "./atomic-json.mjs";
|
|
8
|
+
import { requireStoreIdentity } from "./identity.mjs";
|
|
9
|
+
import { journalEntry, readOpenJournals, rollForward, writeJournalEntry } from "./journal.mjs";
|
|
10
|
+
import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, statePath }
|
|
11
|
+
from "./record-id.mjs";
|
|
12
|
+
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
13
|
+
import { withWriterMutex } from "./writer-mutex.mjs";
|
|
14
|
+
|
|
15
|
+
const SEQUENCE_WIDTH = 16;
|
|
16
|
+
export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
|
|
17
|
+
// No quarantine area. One was created in every workspace, named in the path
|
|
18
|
+
// typedef, and written to by nothing: repair deliberately refuses to move a
|
|
19
|
+
// corrupt record, so nothing ever had a reason to put one aside. An empty
|
|
20
|
+
// directory that reads as a feature is the same mistake as an attention kind
|
|
21
|
+
// with no rule behind it. If quarantining is ever built, it comes back with it.
|
|
22
|
+
const DIRECTORIES = ["state", "events", "journal", "locks", "ephemeral", "tmp"];
|
|
23
|
+
|
|
24
|
+
const pad = value => String(value).padStart(SEQUENCE_WIDTH, "0");
|
|
25
|
+
|
|
26
|
+
export function storePaths(root) {
|
|
27
|
+
return Object.freeze(Object.fromEntries([["root", root],
|
|
28
|
+
...DIRECTORIES.map(name => [name, path.join(root, name)])]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function listState(paths, root, kind) {
|
|
32
|
+
const envelopes = [];
|
|
33
|
+
for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
|
|
34
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
35
|
+
if (found === null) continue;
|
|
36
|
+
envelopes.push(assertStateBinding(found.value, kind,
|
|
37
|
+
path.basename(filePath, ".json"), filePath));
|
|
38
|
+
}
|
|
39
|
+
return envelopes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadAllState(paths, root, wanted = null) {
|
|
43
|
+
const loaded = new Map();
|
|
44
|
+
const kinds = (await listDirectoryEntries(paths.state, { root }))
|
|
45
|
+
.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
46
|
+
.filter(kind => wanted === null || wanted.has(kind));
|
|
47
|
+
for (const kind of kinds) {
|
|
48
|
+
for (const envelope of await listState(paths, root, kind)) {
|
|
49
|
+
loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return loaded;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function nextSequence(paths, root) {
|
|
56
|
+
const last = (await listJsonFiles(paths.events, { root })).at(-1);
|
|
57
|
+
return last === undefined ? 1 : Number(path.basename(last, ".json")) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function openFilesystemStore({ root, clock, ids, workspaceId, failAt }) {
|
|
61
|
+
const paths = storePaths(root);
|
|
62
|
+
// The caller owns the root path, so its ancestors are created here. Inside the
|
|
63
|
+
// root, containment rules apply and each level is created individually so a
|
|
64
|
+
// symlinked ancestor cannot be created past.
|
|
65
|
+
await mkdir(root, { recursive: true });
|
|
66
|
+
for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
|
|
67
|
+
// Identity is settled before any read or write. Adopting a directory that
|
|
68
|
+
// already belongs to another workspace is the failure this fails closed on.
|
|
69
|
+
await requireStoreIdentity(paths, { workspaceId, clock });
|
|
70
|
+
const publishOptions = { root, tmpDir: paths.tmp, clock, failAt };
|
|
71
|
+
|
|
72
|
+
// Any journal left behind by a crashed writer is completed before the store
|
|
73
|
+
// serves a single read, so callers never observe a half-published
|
|
74
|
+
// transaction even on the first open after a crash. Recovery is a write, so
|
|
75
|
+
// it holds the writer mutex: two processes opening the same store at once
|
|
76
|
+
// must not roll the same journal forward concurrently.
|
|
77
|
+
await recoverOpenJournals();
|
|
78
|
+
|
|
79
|
+
async function recoverOpenJournals() {
|
|
80
|
+
const open = await readOpenJournals(paths, root);
|
|
81
|
+
if (open.length === 0) return [];
|
|
82
|
+
return withWriterMutex(paths, { root, tmpDir: paths.tmp, clock }, async () => {
|
|
83
|
+
const completed = [];
|
|
84
|
+
for (const entry of await readOpenJournals(paths, root)) {
|
|
85
|
+
completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock }, entry));
|
|
86
|
+
}
|
|
87
|
+
return completed;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run a write, having read the kinds it declared and no others.
|
|
93
|
+
*
|
|
94
|
+
* A transaction used to read every record the workspace held, for the
|
|
95
|
+
* generation checks `put` makes. That put the cost of `acc message` in
|
|
96
|
+
* proportion to everything the workspace already contained - 400 messages
|
|
97
|
+
* written one after another took 163 seconds, the last of them half a second
|
|
98
|
+
* each - and some of these transactions run inside hooks, where the budget is
|
|
99
|
+
* five seconds and running out means failing open.
|
|
100
|
+
*
|
|
101
|
+
* `kinds` is enforced, not merely honoured: reaching for an undeclared kind
|
|
102
|
+
* throws. A silent empty list would be the worst of both, since every check
|
|
103
|
+
* these transactions make reads as "nothing conflicts" when it finds nothing.
|
|
104
|
+
* Declaring nothing reads everything, which is what this always did.
|
|
105
|
+
*/
|
|
106
|
+
async function transaction(callback, { kinds } = {}) {
|
|
107
|
+
const wanted = kinds === undefined ? null : new Set(kinds);
|
|
108
|
+
const declared = kind => {
|
|
109
|
+
if (wanted !== null && !wanted.has(kind)) {
|
|
110
|
+
throw new AccError(EXIT.DATA,
|
|
111
|
+
`this transaction did not declare ${kind}, so it was never read`,
|
|
112
|
+
{ kind, declared: [...wanted] });
|
|
113
|
+
}
|
|
114
|
+
return kind;
|
|
115
|
+
};
|
|
116
|
+
return withWriterMutex(paths, publishOptions, async () => {
|
|
117
|
+
// Reads are loaded once per transaction so get, list, and the generation
|
|
118
|
+
// that put() compares against all describe the same instant.
|
|
119
|
+
const loaded = await loadAllState(paths, root, wanted);
|
|
120
|
+
const staged = new Map();
|
|
121
|
+
const events = [];
|
|
122
|
+
let sequence = await nextSequence(paths, root);
|
|
123
|
+
const firstSequence = pad(sequence);
|
|
124
|
+
const entryFor = key => {
|
|
125
|
+
const entry = staged.get(key) ?? loaded.get(key) ?? null;
|
|
126
|
+
return entry?.removed === true ? null : entry;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const tx = Object.freeze({
|
|
130
|
+
get(kind, id) {
|
|
131
|
+
declared(kind);
|
|
132
|
+
return entryFor(`${kind}:${id}`)?.record ?? null;
|
|
133
|
+
},
|
|
134
|
+
generationOf(kind, id) {
|
|
135
|
+
declared(kind);
|
|
136
|
+
return entryFor(`${kind}:${id}`)?.generation ?? null;
|
|
137
|
+
},
|
|
138
|
+
list(kind, predicate = () => true) {
|
|
139
|
+
declared(kind);
|
|
140
|
+
const merged = new Map();
|
|
141
|
+
for (const source of [loaded, staged]) {
|
|
142
|
+
for (const [key, entry] of source) {
|
|
143
|
+
if (entry.kind === kind) merged.set(key, entry);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...merged.values()].filter(entry => entry.removed !== true)
|
|
147
|
+
.map(entry => entry.record).filter(predicate);
|
|
148
|
+
},
|
|
149
|
+
put(kind, id, record, expectedGeneration = null) {
|
|
150
|
+
declared(kind);
|
|
151
|
+
const key = `${kind}:${id}`;
|
|
152
|
+
const actual = entryFor(key)?.generation ?? null;
|
|
153
|
+
if (actual !== expectedGeneration) {
|
|
154
|
+
throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
|
|
155
|
+
{ kind, id, expectedGeneration, actualGeneration: actual });
|
|
156
|
+
}
|
|
157
|
+
validateRecord(kind, record);
|
|
158
|
+
const generation = ids.next("generation");
|
|
159
|
+
staged.set(key, { kind, id, record, generation });
|
|
160
|
+
return generation;
|
|
161
|
+
},
|
|
162
|
+
remove(kind, id, expectedGeneration = null) {
|
|
163
|
+
declared(kind);
|
|
164
|
+
const key = `${kind}:${id}`;
|
|
165
|
+
const actual = entryFor(key)?.generation ?? null;
|
|
166
|
+
if (actual !== expectedGeneration) {
|
|
167
|
+
throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
|
|
168
|
+
{ kind, id, expectedGeneration, actualGeneration: actual });
|
|
169
|
+
}
|
|
170
|
+
staged.set(key, { kind, id, removed: true });
|
|
171
|
+
},
|
|
172
|
+
append(event) {
|
|
173
|
+
const stamped = { ...event, sequence: pad(sequence) };
|
|
174
|
+
sequence += 1;
|
|
175
|
+
validateRecord("event", stamped);
|
|
176
|
+
events.push(stamped);
|
|
177
|
+
return stamped;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const result = await callback(tx);
|
|
182
|
+
|
|
183
|
+
// Events are published before state records on purpose: the event log is
|
|
184
|
+
// the authority, and a crash between the two is the window the journal
|
|
185
|
+
// ceiling in eventsSince has to hide.
|
|
186
|
+
const publications = [
|
|
187
|
+
...events.map(event => ({
|
|
188
|
+
path: path.relative(root, eventPath(paths, event.sequence)),
|
|
189
|
+
bytes: encode(event),
|
|
190
|
+
replace: false,
|
|
191
|
+
})),
|
|
192
|
+
...[...staged.values()].map(entry => (entry.removed === true
|
|
193
|
+
? { path: path.relative(root, statePath(paths, entry.kind, entry.id)), remove: true }
|
|
194
|
+
: {
|
|
195
|
+
path: path.relative(root, statePath(paths, entry.kind, entry.id)),
|
|
196
|
+
bytes: encode(stateEnvelope(entry.kind, entry.id, entry.generation, entry.record)),
|
|
197
|
+
replace: true,
|
|
198
|
+
})),
|
|
199
|
+
];
|
|
200
|
+
if (publications.length === 0) return result;
|
|
201
|
+
|
|
202
|
+
const entry = journalEntry(ids.next("transaction"), firstSequence, publications,
|
|
203
|
+
clock.now());
|
|
204
|
+
await writeJournalEntry(paths, publishOptions, entry);
|
|
205
|
+
await failAt?.("after-journal");
|
|
206
|
+
await rollForward(paths, publishOptions, entry);
|
|
207
|
+
return result;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function eventsSince(workspace, cursor, limit) {
|
|
212
|
+
const after = cursor ?? ZERO_CURSOR;
|
|
213
|
+
// An open journal marks a transaction that is decided but not fully
|
|
214
|
+
// published. Bounding the page below its first sequence is what keeps a
|
|
215
|
+
// partially published transaction invisible to every reader.
|
|
216
|
+
const ceiling = (await readOpenJournals(paths, root)).at(0)?.firstSequence ?? null;
|
|
217
|
+
const events = [];
|
|
218
|
+
for (const filePath of await listJsonFiles(paths.events, { root })) {
|
|
219
|
+
const sequence = path.basename(filePath, ".json");
|
|
220
|
+
if (sequence <= after) continue;
|
|
221
|
+
if (ceiling !== null && sequence >= ceiling) break;
|
|
222
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
223
|
+
if (found === null) continue;
|
|
224
|
+
const event = assertEventBinding(found.value, filePath);
|
|
225
|
+
if (event.workspaceId !== workspace) continue;
|
|
226
|
+
events.push(event);
|
|
227
|
+
if (events.length === limit) break;
|
|
228
|
+
}
|
|
229
|
+
return { cursor: events.at(-1)?.sequence ?? after, events };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read the durable state, or the part of it a caller actually needs.
|
|
234
|
+
*
|
|
235
|
+
* Every kind is read by default, which is what most callers want and what
|
|
236
|
+
* this always did. `kinds` exists because one caller runs in front of every
|
|
237
|
+
* file an agent writes: reading the whole store there made the write guard
|
|
238
|
+
* cost grow with the number of messages the workspace had ever carried, and
|
|
239
|
+
* the hook budget is five seconds after which it allows the write.
|
|
240
|
+
*/
|
|
241
|
+
async function snapshot(workspace, { kinds } = {}) {
|
|
242
|
+
const wanted = kinds === undefined ? null : new Set(kinds);
|
|
243
|
+
const of = async kind => {
|
|
244
|
+
if (wanted !== null && !wanted.has(kind)) return [];
|
|
245
|
+
return (await listState(paths, root, kind))
|
|
246
|
+
.map(envelope => envelope.record)
|
|
247
|
+
.filter(record => record.workspaceId === workspace);
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
workspace: (await of("workspace"))[0] ?? null,
|
|
251
|
+
participants: await of("participant"),
|
|
252
|
+
sessions: await of("session"),
|
|
253
|
+
intents: await of("intent"),
|
|
254
|
+
workstreams: await of("workstream"),
|
|
255
|
+
tasks: await of("task"),
|
|
256
|
+
claims: await of("claim"),
|
|
257
|
+
messages: await of("message"),
|
|
258
|
+
receipts: await of("receipt"),
|
|
259
|
+
decisions: await of("decision"),
|
|
260
|
+
handoffs: await of("handoff"),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ephemeral records are published by replace and never journalled: they carry
|
|
265
|
+
// no history, append no events, and are expected to disappear.
|
|
266
|
+
const ephemeralPath = (kind, id) => path.join(paths.ephemeral, kind, `${id}.json`);
|
|
267
|
+
const ephemeral = Object.freeze({
|
|
268
|
+
async get(kind, id) {
|
|
269
|
+
const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
|
|
270
|
+
return found?.value ?? null;
|
|
271
|
+
},
|
|
272
|
+
async put(kind, id, record) {
|
|
273
|
+
await publishAtomic(ephemeralPath(kind, id), encode(record),
|
|
274
|
+
{ root, tmpDir: paths.tmp, replace: true });
|
|
275
|
+
return record;
|
|
276
|
+
},
|
|
277
|
+
async delete(kind, id) {
|
|
278
|
+
await removeIfPresent(ephemeralPath(kind, id));
|
|
279
|
+
},
|
|
280
|
+
async list(kind) {
|
|
281
|
+
const records = [];
|
|
282
|
+
for (const filePath of await listJsonFiles(path.join(paths.ephemeral, kind), { root })) {
|
|
283
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
284
|
+
if (found !== null) records.push(found.value);
|
|
285
|
+
}
|
|
286
|
+
return records;
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return Object.freeze({ transaction, eventsSince, snapshot, ephemeral, paths, root,
|
|
291
|
+
workspaceId });
|
|
292
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
8
|
+
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
9
|
+
|
|
10
|
+
const STALE_MS = 60_000;
|
|
11
|
+
const OWNER = "owner.json";
|
|
12
|
+
|
|
13
|
+
// Directory creation is the atomic primitive: mkdir either creates or fails
|
|
14
|
+
// with EEXIST, with no window in between. Ported from the reconciled
|
|
15
|
+
// prototype's repair mutex and reused here as the per-workspace writer lock.
|
|
16
|
+
function defaultPidIsAlive(pid) {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return error.code !== "ESRCH";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readOwner(directory, root) {
|
|
26
|
+
const found = await readJsonIfPresent(path.join(directory, OWNER), root);
|
|
27
|
+
return found?.value ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reclaim a lock nobody is holding.
|
|
32
|
+
*
|
|
33
|
+
* A hook is a process a client is free to kill, and clients do: they all put a
|
|
34
|
+
* timeout on it. Killed mid-write, it leaves this directory behind with its own
|
|
35
|
+
* pid in it. Requiring the lock to be *both* dead and a minute old meant that
|
|
36
|
+
* for the next sixty seconds no write in the workspace could proceed - no
|
|
37
|
+
* intent, no claim, no message, and no session could attach. Hooks fail open,
|
|
38
|
+
* so none of it was visible: the session simply never appeared, and `acc
|
|
39
|
+
* status` went on reporting the ones that had.
|
|
40
|
+
*
|
|
41
|
+
* A dead owner is reclaimed at once. There is nothing to wait for: the lock
|
|
42
|
+
* holds no state beyond its own owner file, and the process that would have
|
|
43
|
+
* finished the write is gone.
|
|
44
|
+
*
|
|
45
|
+
* The age still matters for the one case liveness cannot answer. A pid is
|
|
46
|
+
* recycled, so a dead owner's number can come back attached to something
|
|
47
|
+
* unrelated, and then `pidIsAlive` says yes forever. Sixty seconds is far longer
|
|
48
|
+
* than any write here takes - the hook budget is five - so a holder that old is
|
|
49
|
+
* not a writer that is still going.
|
|
50
|
+
*/
|
|
51
|
+
async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
|
|
52
|
+
if (owner === null) return false;
|
|
53
|
+
const age = Date.parse(now) - Date.parse(owner.acquiredAt);
|
|
54
|
+
if (pidIsAlive(owner.pid) && !(age > STALE_MS)) return false;
|
|
55
|
+
await rm(directory, { recursive: true, force: true });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function withWriterMutex(paths, options, operation) {
|
|
60
|
+
const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
|
|
61
|
+
attempts = 50, waitMs = 20 } = options;
|
|
62
|
+
const directory = path.join(paths.locks, "writer.lock");
|
|
63
|
+
await ensureManagedDirectory(root, paths.locks);
|
|
64
|
+
const token = uuid();
|
|
65
|
+
|
|
66
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(directory);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST") throw error;
|
|
71
|
+
const owner = await readOwner(directory, root);
|
|
72
|
+
if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
|
|
73
|
+
await new Promise(resolve => { setTimeout(resolve, waitMs); });
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const owner = { pid: process.pid, token, acquiredAt: clock.now() };
|
|
78
|
+
await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
|
|
79
|
+
try {
|
|
80
|
+
return await operation();
|
|
81
|
+
} finally {
|
|
82
|
+
const current = await readOwner(directory, root);
|
|
83
|
+
if (current?.token === token) await rm(directory, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new AccError(EXIT.CONFLICT, "another writer holds the store lock", { directory });
|
|
87
|
+
}
|