agents-can-communicate 0.9.1 → 0.10.1
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 +46 -44
- package/bin/acc-update-worker.mjs +2 -1
- package/bin/entrypoints/hook-output.mjs +2 -0
- package/docs/ARCHITECTURE.md +26 -2
- package/docs/CLI.md +11 -1
- package/docs/GETTING_STARTED.md +4 -3
- package/docs/UPGRADING.md +62 -3
- package/docs/WHY_ACC.md +32 -9
- package/docs/index.md +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +12 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +11 -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/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +6 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +4 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +14 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +97 -10
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +4 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +17 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/store-upgrade.mjs +105 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/store-migration-command.mjs +39 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +21 -52
- package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +16 -11
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +12 -4
- package/node_modules/@agents-can-communicate/core/src/message-retry.mjs +43 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +12 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +20 -6
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +23 -3
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/protocol/src/transaction-indexes.mjs +22 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +22 -10
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-cache.mjs +144 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-io.mjs +65 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-pages.mjs +85 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim-progress.mjs +35 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-reclaim.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index-tree.mjs +142 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +2 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +41 -20
- package/node_modules/@agents-can-communicate/storage-filesystem/src/prune-durability.mjs +29 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +13 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +9 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +6 -4
- package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +43 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store-migration.mjs +56 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +61 -110
- package/node_modules/@agents-can-communicate/storage-filesystem/src/transaction-view.mjs +131 -0
- package/package.json +5 -4
|
@@ -74,8 +74,22 @@ export async function renameEntry(from, to, { platform = process.platform,
|
|
|
74
74
|
}, error => BUSY.has(error.code), { deadlineAt, sleep });
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// A name inside a directory another process is deleting - a waiting writer's
|
|
78
|
+
// rmdir of a lock its owner emptied - answers lstat with EPERM until the
|
|
79
|
+
// deletion is done (measured on windows-latest: lstat of writer.lock\owner.json
|
|
80
|
+
// while eight writers elected one, 2026-10-06, and the waiting writer failed
|
|
81
|
+
// instead of looking again). The name is asked again within the caller's
|
|
82
|
+
// deadline: what it settles to is the answer, and an EPERM that outlasts the
|
|
83
|
+
// deadline is kept.
|
|
84
|
+
async function lstatSettled(file, lstat, { deadlineAt, sleep }) {
|
|
85
|
+
try {
|
|
86
|
+
return await retrying(() => lstat(file, { bigint: true }), error => error.code === "EPERM",
|
|
87
|
+
{ deadlineAt, sleep });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === "ENOENT") return null;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
79
93
|
|
|
80
94
|
const refusedLink = file => Object.assign(
|
|
81
95
|
new Error(`ELOOP: too many symbolic links encountered, open '${file}'`),
|
|
@@ -99,32 +113,39 @@ export async function openNoFollow(file, flags, { mode, platform = process.platf
|
|
|
99
113
|
open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
|
|
100
114
|
if (!isWindows(platform)) return open(file, flags | constants.O_NOFOLLOW, mode);
|
|
101
115
|
for (let attempt = 1; ; attempt += 1) {
|
|
102
|
-
const before = await
|
|
103
|
-
if (error.code === "ENOENT") return null;
|
|
104
|
-
throw error;
|
|
105
|
-
});
|
|
116
|
+
const before = await lstatSettled(file, lstat, { deadlineAt, sleep });
|
|
106
117
|
if (before?.isSymbolicLink()) throw refusedLink(file);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
let handle;
|
|
119
|
+
try {
|
|
120
|
+
// A scanner that opened the file without read sharing refuses every
|
|
121
|
+
// reader with EBUSY until it lets go.
|
|
122
|
+
handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
|
|
123
|
+
{ deadlineAt, sleep });
|
|
124
|
+
} catch (error) {
|
|
111
125
|
// A name another process is deleting, or a lock changing hands around
|
|
112
126
|
// it, refuses the open with EPERM, and the name is gone right after
|
|
113
127
|
// (measured on windows-latest, for a name present before the open and
|
|
114
128
|
// for one absent before it): that is ENOENT, as Linux says it. A name
|
|
115
|
-
// that
|
|
116
|
-
//
|
|
129
|
+
// that names another file by then changed hands - a lock released and
|
|
130
|
+
// taken again while the open was refused (measured on windows-latest,
|
|
131
|
+
// 2026-10-06) - and is opened afresh, as a swap after the open is. A
|
|
132
|
+
// name still naming what it named keeps its EPERM, and so does a create
|
|
133
|
+
// on a name that was free, which is a refused create.
|
|
117
134
|
const refusedCreate = before === null && (flags & constants.O_CREAT) !== 0;
|
|
118
|
-
if (error.code !== "EPERM" || refusedCreate
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
if (error.code !== "EPERM" || refusedCreate) throw error;
|
|
136
|
+
const now = await lstatSettled(file, lstat, { deadlineAt, sleep }).catch(() => undefined);
|
|
137
|
+
if (now === null) {
|
|
138
|
+
throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
|
|
139
|
+
{ cause: error }), { code: "ENOENT", syscall: "open", path: file });
|
|
140
|
+
}
|
|
141
|
+
const changedHands = now !== undefined
|
|
142
|
+
&& (before === null || now.dev !== before.dev || now.ino !== before.ino);
|
|
143
|
+
if (changedHands && attempt < SWAPS) continue;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
122
146
|
try {
|
|
123
147
|
const opened = await handle.stat({ bigint: true });
|
|
124
|
-
const named = await
|
|
125
|
-
if (error.code === "ENOENT") return null;
|
|
126
|
-
throw error;
|
|
127
|
-
});
|
|
148
|
+
const named = await lstatSettled(file, lstat, { deadlineAt, sleep });
|
|
128
149
|
if (named?.isSymbolicLink()) throw refusedLink(file);
|
|
129
150
|
if (named !== null && named.dev === opened.dev && named.ino === opened.ino) return handle;
|
|
130
151
|
if (attempt >= SWAPS) throw refusedLink(file);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { doomedDirectories } from "./doomed-directory.mjs";
|
|
3
|
+
import { isWindows, syncEntry } from "./portable-fs.mjs";
|
|
4
|
+
import { assertManagedDirectory } from "./safe-directory.mjs";
|
|
5
|
+
|
|
6
|
+
// A decided prune finishes its applied prefix even after its deadline. On
|
|
7
|
+
// Windows the final idle-journal file flush commits earlier NTFS renames.
|
|
8
|
+
export async function syncPruneDirectories(root, directories, { platform } = {}) {
|
|
9
|
+
if (isWindows(platform)) return;
|
|
10
|
+
for (const directory of directories) {
|
|
11
|
+
try { await assertManagedDirectory(root, directory); }
|
|
12
|
+
catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
13
|
+
await syncEntry(directory, directory, { platform });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Empty-publication journals are physical indexed prunes. Process death loses
|
|
18
|
+
// their in-memory affected-directory set; conservatively fence every possible
|
|
19
|
+
// primary/marker parent before idle permits a current negative cache. No new
|
|
20
|
+
// journal format or per-record publication is needed.
|
|
21
|
+
export async function recoverPruneDurability(paths, { root, platform } = {}) {
|
|
22
|
+
if (isWindows(platform)) return;
|
|
23
|
+
const directories = [
|
|
24
|
+
...["message", "receipt"].flatMap(kind => [path.join(paths.state, kind),
|
|
25
|
+
path.join(paths.retained, "state", kind)]),
|
|
26
|
+
...await doomedDirectories(root), root,
|
|
27
|
+
];
|
|
28
|
+
await syncPruneDirectories(root, directories, { platform });
|
|
29
|
+
}
|
|
@@ -7,6 +7,7 @@ import { listDirectoryEntries, readJsonIfPresent } from "./atomic-json.mjs";
|
|
|
7
7
|
import { condemn, detachDoomed, discard, discardLeftovers, expired } from "./doomed-directory.mjs";
|
|
8
8
|
import { assertSequence, raiseEventFloor, readEventFloor } from "./event-floor.mjs";
|
|
9
9
|
import { statePath } from "./record-id.mjs";
|
|
10
|
+
import { reclaimIndexPages } from "./index-reclaim.mjs";
|
|
10
11
|
|
|
11
12
|
// Shared with the stage sweep that calls this, so one pass cannot spend two
|
|
12
13
|
// budgets. Exported for the caller that wants to reclaim without a bound.
|
|
@@ -148,8 +149,14 @@ export async function reclaimRetired(paths,
|
|
|
148
149
|
|
|
149
150
|
const removed = await discard(doomed, root, limit - spent, deadlineAt);
|
|
150
151
|
reclaimed += removed.spent;
|
|
152
|
+
spent += removed.spent;
|
|
153
|
+
const indexes = journals.drained && markers.drained && removed.drained
|
|
154
|
+
? await reclaimIndexPages(paths, { root, limit: limit - spent, deadlineAt })
|
|
155
|
+
: { reclaimed: 0, remaining: false };
|
|
156
|
+
reclaimed += indexes.reclaimed;
|
|
151
157
|
return { reclaimed,
|
|
152
|
-
remaining: !journals.drained || !markers.drained || !removed.drained
|
|
158
|
+
remaining: !journals.drained || !markers.drained || !removed.drained
|
|
159
|
+
|| (indexes.remaining && !indexes.deferred) };
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
/**
|
|
@@ -172,7 +179,7 @@ export async function reclaimRetired(paths,
|
|
|
172
179
|
* @returns {Promise<{ reclaimed: number, skipped: number, remaining: boolean }>}
|
|
173
180
|
*/
|
|
174
181
|
export async function reclaimStateRecords(paths, entries,
|
|
175
|
-
{ root, limit = RECLAIM_BUDGET, deadlineAt } = {}) {
|
|
182
|
+
{ root, limit = RECLAIM_BUDGET, deadlineAt, onCondemned } = {}) {
|
|
176
183
|
if (expired(deadlineAt)) return { reclaimed: 0, skipped: 0, remaining: entries.length > 0 };
|
|
177
184
|
let spent = 0;
|
|
178
185
|
let skipped = 0;
|
|
@@ -202,10 +209,13 @@ export async function reclaimStateRecords(paths, entries,
|
|
|
202
209
|
continue;
|
|
203
210
|
}
|
|
204
211
|
await condemn(filePath, doomed);
|
|
212
|
+
await onCondemned?.(filePath, doomed);
|
|
205
213
|
spent += 1;
|
|
206
214
|
condemned += 1;
|
|
207
215
|
try {
|
|
208
|
-
|
|
216
|
+
const markers = path.join(paths.retained, "state", entry.kind, entry.id);
|
|
217
|
+
await condemn(markers, doomed);
|
|
218
|
+
await onCondemned?.(markers, doomed);
|
|
209
219
|
spent += 1;
|
|
210
220
|
// Counted only when it moved. Most records own no marker directory at
|
|
211
221
|
// all, and counting one anyway made `remaining` compare what was
|
|
@@ -18,8 +18,11 @@ import { withWriterMutex } from "./writer-mutex.mjs";
|
|
|
18
18
|
* trimmedThrough: string|null }} RepairReport */
|
|
19
19
|
|
|
20
20
|
const report = ({ repaired = [], blocked = [], corrupt = [], swept = 0, staged = 0,
|
|
21
|
-
partials = 0, retired = 0, trimmedThrough = null }) => ({
|
|
21
|
+
partials = 0, retired = 0, trimmedThrough = null, migrationRequired = false }) => ({
|
|
22
22
|
healthy: blocked.length === 0 && corrupt.length === 0,
|
|
23
|
+
// An older contract, not an unreadable file: the identity read and named its
|
|
24
|
+
// version. The reader names the migration rather than calling it ambiguous.
|
|
25
|
+
...(migrationRequired ? { migrationRequired: true } : {}),
|
|
23
26
|
repaired: [...repaired].sort(),
|
|
24
27
|
blocked: [...blocked].sort(),
|
|
25
28
|
corrupt: [...corrupt].sort(),
|
|
@@ -75,7 +78,8 @@ async function inspect(root) {
|
|
|
75
78
|
identity = await readStoreIdentity(paths);
|
|
76
79
|
} catch (error) {
|
|
77
80
|
blocked.push(path.join(root, "protocol.json"));
|
|
78
|
-
return { paths, identity: null, pending, blocked, corrupt, reason: error.message
|
|
81
|
+
return { paths, identity: null, pending, blocked, corrupt, reason: error.message,
|
|
82
|
+
migrationRequired: error?.details?.reasonCode === "store_migration_required" };
|
|
79
83
|
}
|
|
80
84
|
if (identity === null) blocked.push(path.join(root, "protocol.json"));
|
|
81
85
|
|
|
@@ -123,6 +127,7 @@ export async function diagnoseFilesystemStore({ root }) {
|
|
|
123
127
|
return report({
|
|
124
128
|
blocked: state.blocked,
|
|
125
129
|
corrupt: state.corrupt,
|
|
130
|
+
migrationRequired: state.migrationRequired === true,
|
|
126
131
|
repaired: state.pending.map(entry => entry.transactionId),
|
|
127
132
|
...await countStaging(state.paths, root),
|
|
128
133
|
});
|
|
@@ -133,7 +138,8 @@ export async function repairFilesystemStore({ root, clock }) {
|
|
|
133
138
|
if (state.blocked.length > 0 || state.corrupt.length > 0) {
|
|
134
139
|
// Fail closed. Completing a journal on top of state we cannot even read
|
|
135
140
|
// would turn an ambiguous store into a confidently wrong one.
|
|
136
|
-
return report({ blocked: state.blocked, corrupt: state.corrupt
|
|
141
|
+
return report({ blocked: state.blocked, corrupt: state.corrupt,
|
|
142
|
+
migrationRequired: state.migrationRequired === true });
|
|
137
143
|
}
|
|
138
144
|
// No early return for an empty journal any more: a store with nothing to roll
|
|
139
145
|
// forward is exactly the store whose staging directory needs reclaiming.
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { assertPortableId, validateRecord } from "@agents-can-communicate/protocol";
|
|
3
3
|
import { readJsonIfPresent } from "./atomic-json.mjs";
|
|
4
|
-
import { requireStoreIdentity } from "./identity.mjs";
|
|
4
|
+
import { readMigrationIdentity, requireStoreIdentity } from "./identity.mjs";
|
|
5
5
|
import { assertStateBinding, statePath } from "./record-id.mjs";
|
|
6
6
|
import { ephemeralIsDeleted, stateGenerationIsDeleted } from "./retention.mjs";
|
|
7
7
|
import { storePaths } from "./store.mjs";
|
|
8
8
|
|
|
9
|
-
/** Inspect ownership without opening, creating, recovering, or locking the store.
|
|
10
|
-
|
|
9
|
+
/** Inspect ownership without opening, creating, recovering, or locking the store.
|
|
10
|
+
* Legacy identity inspection is read-only metadata for runtime hold classification. */
|
|
11
|
+
export async function readSessionRecord({ root, workspaceId, sessionId, allowLegacyIdentity = false }) {
|
|
11
12
|
assertPortableId(sessionId, "session id");
|
|
12
13
|
const paths = storePaths(root);
|
|
13
|
-
await
|
|
14
|
+
if (allowLegacyIdentity === true) await readMigrationIdentity(paths, workspaceId);
|
|
15
|
+
else await requireStoreIdentity(paths, { workspaceId, create: false });
|
|
14
16
|
const file = statePath(paths, "session", sessionId);
|
|
15
17
|
const durable = await readJsonIfPresent(file, root);
|
|
16
18
|
if (durable !== null) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
|
|
3
|
+
import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
|
|
4
|
+
import { assertStateBinding, statePath } from "./record-id.mjs";
|
|
5
|
+
import { stateGenerationIsDeleted } from "./retention.mjs";
|
|
6
|
+
import { assertPublicationDeadline } from "./deadline.mjs";
|
|
7
|
+
|
|
8
|
+
export async function readStateEnvelope(paths, { root, workspaceId }, kind, id) {
|
|
9
|
+
const filePath = statePath(paths, kind, id);
|
|
10
|
+
const found = await readJsonIfPresent(filePath, root);
|
|
11
|
+
if (found === null) return null;
|
|
12
|
+
const envelope = assertStateBinding(found.value, kind, id, filePath);
|
|
13
|
+
if (await stateGenerationIsDeleted(paths, root, kind, id, envelope.generation)) return null;
|
|
14
|
+
validateRecord(kind, envelope.record);
|
|
15
|
+
if (workspaceId !== undefined && envelope.record.workspaceId !== workspaceId) {
|
|
16
|
+
throw new AccError(EXIT.DATA, "state record belongs to a different workspace",
|
|
17
|
+
{ filePath, expected: workspaceId, actual: envelope.record.workspaceId });
|
|
18
|
+
}
|
|
19
|
+
return envelope;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listState(paths, root, kind, deadlineAt) {
|
|
23
|
+
const envelopes = [];
|
|
24
|
+
for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
|
|
25
|
+
assertPublicationDeadline(deadlineAt);
|
|
26
|
+
const envelope = await readStateEnvelope(paths, { root }, kind, path.basename(filePath, ".json"));
|
|
27
|
+
if (envelope !== null) envelopes.push(envelope);
|
|
28
|
+
}
|
|
29
|
+
return envelopes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadStateEnvelopes(paths, { root, deadlineAt }, wanted = null) {
|
|
33
|
+
const loaded = new Map();
|
|
34
|
+
const kinds = (await listDirectoryEntries(paths.state, { root }))
|
|
35
|
+
.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
36
|
+
.filter(kind => wanted === null || wanted.has(kind));
|
|
37
|
+
for (const kind of kinds) {
|
|
38
|
+
for (const envelope of await listState(paths, root, kind, deadlineAt)) {
|
|
39
|
+
loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return loaded;
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol";
|
|
2
|
+
import { encode, listJsonFiles, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
3
|
+
import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
|
|
4
|
+
import { assertPublicationDeadline } from "./deadline.mjs";
|
|
5
|
+
import { identityPath, readMigrationIdentity, STORE_VERSION } from "./identity.mjs";
|
|
6
|
+
import { createIndexCache } from "./index-cache.mjs";
|
|
7
|
+
import { IndexCacheUnavailable } from "./index-pages.mjs";
|
|
8
|
+
import { readOpenJournals, rollForward } from "./journal.mjs";
|
|
9
|
+
import { assertEventBinding } from "./record-id.mjs";
|
|
10
|
+
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
11
|
+
import { loadStateEnvelopes } from "./state-reads.mjs";
|
|
12
|
+
import { storePaths } from "./store.mjs";
|
|
13
|
+
import { withWriterMutex } from "./writer-mutex.mjs";
|
|
14
|
+
|
|
15
|
+
/** Explicit library migration; the operator must quiesce unmanaged users first. */
|
|
16
|
+
export async function migrateFilesystemStore({ root, workspaceId, clock, ids,
|
|
17
|
+
allowMigration = false, platform = process.platform, deadlineAt, failAt }) {
|
|
18
|
+
if (allowMigration !== true) {
|
|
19
|
+
throw new AccError(EXIT.USAGE, "store migration requires explicit opt-in after quiescing other users");
|
|
20
|
+
}
|
|
21
|
+
assertPublicationDeadline(deadlineAt);
|
|
22
|
+
const paths = storePaths(root), publishOptions = { root, tmpDir: paths.tmp, clock, platform, failAt };
|
|
23
|
+
await readMigrationIdentity(paths, workspaceId);
|
|
24
|
+
return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
|
|
25
|
+
const identity = await readMigrationIdentity(paths, workspaceId), fromVersion = identity.storeVersion;
|
|
26
|
+
for (const directory of Object.values(paths)) await ensureManagedDirectory(root, directory);
|
|
27
|
+
await initialiseActiveJournal(paths, { ...publishOptions, deadlineAt });
|
|
28
|
+
for (const entry of await readOpenJournals(paths, root)) {
|
|
29
|
+
// A decided journal finishes even if the migration's pre-switch budget lapses.
|
|
30
|
+
await rollForward(paths, publishOptions, entry);
|
|
31
|
+
}
|
|
32
|
+
assertPublicationDeadline(deadlineAt);
|
|
33
|
+
const primaries = await loadStateEnvelopes(paths, { root, deadlineAt });
|
|
34
|
+
for (const file of await listJsonFiles(paths.events, { root })) {
|
|
35
|
+
assertPublicationDeadline(deadlineAt);
|
|
36
|
+
const found = await readJsonIfPresent(file, root);
|
|
37
|
+
if (found === null) continue;
|
|
38
|
+
const event = validateRecord("event", assertEventBinding(found.value, file));
|
|
39
|
+
if (event.workspaceId !== workspaceId) throw new AccError(EXIT.DATA, "event belongs to a different workspace");
|
|
40
|
+
}
|
|
41
|
+
const cache = createIndexCache({ paths, root, workspaceId, publishOptions, loadPrimary: async () => primaries });
|
|
42
|
+
try { await cache.rebuild(await readActiveJournal(paths, root), deadlineAt, { required: true }); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!(error instanceof IndexCacheUnavailable)) throw error;
|
|
45
|
+
throw new AccError(EXIT.DATA, "store migration could not publish its complete index", { reason: error.reason });
|
|
46
|
+
}
|
|
47
|
+
if (fromVersion !== STORE_VERSION) {
|
|
48
|
+
await failAt?.("before-store-version-switch");
|
|
49
|
+
assertPublicationDeadline(deadlineAt);
|
|
50
|
+
await publishAtomic(identityPath(paths), encode({ ...identity, storeVersion: STORE_VERSION }),
|
|
51
|
+
{ ...publishOptions, deadlineAt, replace: true, durability: "full" });
|
|
52
|
+
await failAt?.("after-store-version-switch");
|
|
53
|
+
}
|
|
54
|
+
return { fromVersion, toVersion: STORE_VERSION, migrated: fromVersion !== STORE_VERSION };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { AccError, EXIT, assertPortableId, validateRecord }
|
|
4
|
+
import { AccError, EXIT, TRANSACTION_INDEXES, assertIndexTuple, assertPortableId, indexKeysFor, validateRecord }
|
|
5
5
|
from "@agents-can-communicate/protocol";
|
|
6
6
|
|
|
7
|
-
import { encode,
|
|
7
|
+
import { encode, listJsonFiles, publishAtomic, readJsonIfPresent,
|
|
8
8
|
retainFile } from "./atomic-json.mjs";
|
|
9
9
|
import { initialiseActiveJournal, readActiveJournal } from "./active-journal.mjs";
|
|
10
10
|
import { publicationPath } from "./publication-path.mjs";
|
|
@@ -12,15 +12,19 @@ import { assertPublicationDeadline } from "./deadline.mjs";
|
|
|
12
12
|
import { requireStoreIdentity } from "./identity.mjs";
|
|
13
13
|
import { journalEntry, readJournalCeiling, readOpenJournals, rollForward, writeJournalEntry }
|
|
14
14
|
from "./journal.mjs";
|
|
15
|
-
import { assertEventBinding,
|
|
15
|
+
import { assertEventBinding, eventPath, stateEnvelope, statePath }
|
|
16
16
|
from "./record-id.mjs";
|
|
17
|
-
import { ephemeralIsDeleted, markEphemeral, stateDeletionPublication
|
|
18
|
-
stateGenerationIsDeleted } from "./retention.mjs";
|
|
17
|
+
import { ephemeralIsDeleted, markEphemeral, stateDeletionPublication } from "./retention.mjs";
|
|
19
18
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
20
19
|
import { readEventFloor } from "./event-floor.mjs";
|
|
21
20
|
import { reclaimStateRecords, trimEventLog } from "./reclaim.mjs";
|
|
22
21
|
import { sweepIfDue } from "./stage-sweep.mjs";
|
|
23
22
|
import { withWriterMutex } from "./writer-mutex.mjs";
|
|
23
|
+
import { listState, loadStateEnvelopes, readStateEnvelope } from "./state-reads.mjs";
|
|
24
|
+
import { createTransactionView, transactionKinds } from "./transaction-view.mjs";
|
|
25
|
+
import { createIndexCache } from "./index-cache.mjs";
|
|
26
|
+
import { IndexCacheUnavailable } from "./index-pages.mjs";
|
|
27
|
+
import { withIndexedPrune } from "./index-reclaim.mjs";
|
|
24
28
|
|
|
25
29
|
// Kept cohesive above 300 lines because durable transactions and ephemeral
|
|
26
30
|
// mutations must share this exact writer mutex. Splitting the two stores would
|
|
@@ -55,34 +59,6 @@ export function storePaths(root) {
|
|
|
55
59
|
...DIRECTORIES.map(name => [name, path.join(root, name)])]));
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
async function listState(paths, root, kind) {
|
|
59
|
-
const envelopes = [];
|
|
60
|
-
for (const filePath of await listJsonFiles(path.join(paths.state, kind), { root })) {
|
|
61
|
-
const found = await readJsonIfPresent(filePath, root);
|
|
62
|
-
if (found === null) continue;
|
|
63
|
-
const envelope = assertStateBinding(found.value, kind,
|
|
64
|
-
path.basename(filePath, ".json"), filePath);
|
|
65
|
-
if (await stateGenerationIsDeleted(paths, root, kind, envelope.id,
|
|
66
|
-
envelope.generation)) continue;
|
|
67
|
-
validateRecord(kind, envelope.record);
|
|
68
|
-
envelopes.push(envelope);
|
|
69
|
-
}
|
|
70
|
-
return envelopes;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function loadAllState(paths, root, wanted = null) {
|
|
74
|
-
const loaded = new Map();
|
|
75
|
-
const kinds = (await listDirectoryEntries(paths.state, { root }))
|
|
76
|
-
.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
77
|
-
.filter(kind => wanted === null || wanted.has(kind));
|
|
78
|
-
for (const kind of kinds) {
|
|
79
|
-
for (const envelope of await listState(paths, root, kind)) {
|
|
80
|
-
loaded.set(`${envelope.kind}:${envelope.id}`, envelope);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return loaded;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
62
|
async function nextSequence(paths, root) {
|
|
87
63
|
const last = (await listJsonFiles(paths.events, { root })).at(-1);
|
|
88
64
|
// Only an empty directory consults the floor, so the path every transaction
|
|
@@ -118,6 +94,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
118
94
|
await requireStoreIdentity(paths, { workspaceId, clock, platform });
|
|
119
95
|
for (const name of DIRECTORIES) await ensureManagedDirectory(root, paths[name]);
|
|
120
96
|
const publishOptions = { root, tmpDir: paths.tmp, clock, failAt, platform };
|
|
97
|
+
const indexCache = createIndexCache({ paths, root, workspaceId, publishOptions,
|
|
98
|
+
loadPrimary: options => loadStateEnvelopes(paths, { root, ...options }, new Set(["message", "receipt"])) });
|
|
121
99
|
await initialiseActiveJournal(paths, publishOptions);
|
|
122
100
|
|
|
123
101
|
// Any journal left behind by a crashed writer is completed before the store
|
|
@@ -149,7 +127,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
149
127
|
return withWriterMutex(paths, { root, tmpDir: paths.tmp, clock, deadlineAt: storeDeadline }, async () => {
|
|
150
128
|
const completed = [];
|
|
151
129
|
for (const entry of await readOpenJournals(paths, root)) {
|
|
152
|
-
completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock }, entry));
|
|
130
|
+
completed.push(...await rollForward(paths, { root, tmpDir: paths.tmp, clock, platform }, entry));
|
|
153
131
|
}
|
|
154
132
|
return completed;
|
|
155
133
|
});
|
|
@@ -170,87 +148,58 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
170
148
|
* these transactions make reads as "nothing conflicts" when it finds nothing.
|
|
171
149
|
* Declaring nothing reads everything, which is what this always did.
|
|
172
150
|
*/
|
|
173
|
-
async function transaction(callback, { kinds, deadlineAt } = {}) {
|
|
151
|
+
async function transaction(callback, { kinds, exactKinds = [], deadlineAt } = {}) {
|
|
174
152
|
// A caller may tighten this invocation's budget, never extend it.
|
|
175
153
|
deadlineAt = Math.min(deadlineAt ?? Infinity, storeDeadline ?? Infinity);
|
|
176
|
-
const
|
|
177
|
-
const declared = kind => {
|
|
178
|
-
if (wanted !== null && !wanted.has(kind)) {
|
|
179
|
-
throw new AccError(EXIT.DATA,
|
|
180
|
-
`this transaction did not declare ${kind}, so it was never read`,
|
|
181
|
-
{ kind, declared: [...wanted] });
|
|
182
|
-
}
|
|
183
|
-
return kind;
|
|
184
|
-
};
|
|
154
|
+
const { eager } = transactionKinds(kinds, exactKinds);
|
|
185
155
|
return withWriterMutex(paths, { ...publishOptions, deadlineAt }, async () => {
|
|
186
156
|
assertPublicationDeadline(deadlineAt);
|
|
157
|
+
// An already-open handle may follow a writer that died after deciding.
|
|
158
|
+
// Recovery here holds this mutex directly, never trying to acquire it twice.
|
|
159
|
+
for (const entry of await readOpenJournals(paths, root)) await rollForward(paths, publishOptions, entry);
|
|
160
|
+
const before = await readActiveJournal(paths, root);
|
|
187
161
|
// Reads are loaded once per transaction so get, list, and the generation
|
|
188
162
|
// that put() compares against all describe the same instant.
|
|
189
|
-
const loaded = await
|
|
190
|
-
const staged = new Map();
|
|
163
|
+
const loaded = await loadStateEnvelopes(paths, { root }, eager);
|
|
191
164
|
const events = [];
|
|
192
165
|
let sequence = await nextSequence(paths, root);
|
|
193
166
|
const firstSequence = pad(sequence);
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const tx = Object.freeze({
|
|
200
|
-
get(kind, id) {
|
|
201
|
-
declared(kind);
|
|
202
|
-
return entryFor(`${kind}:${id}`)?.record ?? null;
|
|
203
|
-
},
|
|
204
|
-
generationOf(kind, id) {
|
|
205
|
-
declared(kind);
|
|
206
|
-
return entryFor(`${kind}:${id}`)?.generation ?? null;
|
|
207
|
-
},
|
|
208
|
-
list(kind, predicate = () => true) {
|
|
209
|
-
declared(kind);
|
|
210
|
-
const merged = new Map();
|
|
211
|
-
for (const source of [loaded, staged]) {
|
|
212
|
-
for (const [key, entry] of source) {
|
|
213
|
-
if (entry.kind === kind) merged.set(key, entry);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return [...merged.values()].filter(entry => entry.removed !== true)
|
|
217
|
-
.map(entry => entry.record).filter(predicate);
|
|
218
|
-
},
|
|
219
|
-
put(kind, id, record, expectedGeneration = null) {
|
|
220
|
-
declared(kind);
|
|
221
|
-
const key = `${kind}:${id}`;
|
|
222
|
-
const actual = entryFor(key)?.generation ?? null;
|
|
223
|
-
if (actual !== expectedGeneration) {
|
|
224
|
-
throw new AccError(EXIT.CONFLICT, `${kind} ${id} changed under this transaction`,
|
|
225
|
-
{ kind, id, expectedGeneration, actualGeneration: actual });
|
|
226
|
-
}
|
|
227
|
-
validateRecord(kind, record);
|
|
228
|
-
const generation = ids.next("generation");
|
|
229
|
-
staged.set(key, { kind, id, record, generation });
|
|
230
|
-
return generation;
|
|
167
|
+
const view = createTransactionView({ kinds, exactKinds, loaded, ids,
|
|
168
|
+
loadEnvelope: (kind, id) => {
|
|
169
|
+
assertPublicationDeadline(deadlineAt);
|
|
170
|
+
return readStateEnvelope(paths, { root, workspaceId }, kind, id);
|
|
231
171
|
},
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
172
|
+
lookupIndex(index, tuple, context) {
|
|
173
|
+
assertIndexTuple(index, tuple);
|
|
174
|
+
const kind = TRANSACTION_INDEXES[index].kind;
|
|
175
|
+
context.declared(kind);
|
|
176
|
+
return indexCache.lookup(index, tuple, { active: before, staged: context.staged, deadlineAt,
|
|
177
|
+
verify: async id => {
|
|
178
|
+
const envelope = await context.read(kind, id);
|
|
179
|
+
if (envelope === null) return false;
|
|
180
|
+
if (!indexKeysFor(kind, envelope.record).some(key => key.index === index
|
|
181
|
+
&& JSON.stringify(key.tuple) === JSON.stringify(tuple))) {
|
|
182
|
+
throw new IndexCacheUnavailable("invalid_source_reference");
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
} });
|
|
243
186
|
},
|
|
244
|
-
|
|
187
|
+
appendEvent(event) {
|
|
245
188
|
const stamped = { ...event, sequence: pad(sequence) };
|
|
246
189
|
sequence += 1;
|
|
247
190
|
validateRecord("event", stamped);
|
|
248
191
|
events.push(stamped);
|
|
249
192
|
return stamped;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
193
|
+
} });
|
|
194
|
+
const { tx, staged } = view;
|
|
195
|
+
let result;
|
|
196
|
+
try { result = await callback(tx); }
|
|
197
|
+
catch (error) {
|
|
198
|
+
await view.finish().catch(() => {});
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
await view.finish();
|
|
202
|
+
const deltas = indexCache.prepareDeltas(loaded, staged);
|
|
254
203
|
|
|
255
204
|
// Events are published before state records on purpose: the event log is
|
|
256
205
|
// the authority, and a crash between the two is the window the journal
|
|
@@ -281,7 +230,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
281
230
|
// journalled write too, which the path below keeps.
|
|
282
231
|
const [only] = staged.values();
|
|
283
232
|
if (events.length === 0 && staged.size === 1 && only.removed !== true
|
|
284
|
-
&&
|
|
233
|
+
&& !["message", "receipt"].includes(only.kind) && before.state === "idle") {
|
|
285
234
|
assertPublicationDeadline(deadlineAt);
|
|
286
235
|
await publishAtomic(statePath(paths, only.kind, only.id), publications[0].bytes,
|
|
287
236
|
{ ...publishOptions, replace: true, deadlineAt });
|
|
@@ -297,6 +246,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
297
246
|
await writeJournalEntry(paths, { ...publishOptions, deadlineAt }, entry);
|
|
298
247
|
await failAt?.("after-journal");
|
|
299
248
|
await rollForward(paths, publishOptions, entry);
|
|
249
|
+
await indexCache.commit({ before, after: await readActiveJournal(paths, root), deltas, deadlineAt });
|
|
300
250
|
return result;
|
|
301
251
|
});
|
|
302
252
|
}
|
|
@@ -343,13 +293,8 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
343
293
|
* pass over every session the workspace ever had, several times a hook.
|
|
344
294
|
*/
|
|
345
295
|
async function stateRecord(workspace, kind, id) {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
if (found === null) return null;
|
|
349
|
-
const envelope = assertStateBinding(found.value, kind, id, filePath);
|
|
350
|
-
if (await stateGenerationIsDeleted(paths, root, kind, envelope.id, envelope.generation)) return null;
|
|
351
|
-
validateRecord(kind, envelope.record);
|
|
352
|
-
return envelope.record.workspaceId === workspace ? envelope.record : null;
|
|
296
|
+
const envelope = await readStateEnvelope(paths, { root }, kind, id);
|
|
297
|
+
return envelope?.record.workspaceId === workspace ? envelope.record : null;
|
|
353
298
|
}
|
|
354
299
|
|
|
355
300
|
async function snapshot(workspace, { kinds } = {}) {
|
|
@@ -446,7 +391,7 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
446
391
|
*/
|
|
447
392
|
async function stateEnvelopes(workspace, { kinds } = {}) {
|
|
448
393
|
const wanted = kinds === undefined ? null : new Set(kinds);
|
|
449
|
-
return [...(await
|
|
394
|
+
return [...(await loadStateEnvelopes(paths, { root }, wanted)).values()]
|
|
450
395
|
.filter(envelope => envelope.record.workspaceId === workspace);
|
|
451
396
|
}
|
|
452
397
|
|
|
@@ -460,13 +405,18 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
460
405
|
async function reclaimRecords(plan, { limit, deadlineAt } = {}) {
|
|
461
406
|
const bounded = Math.min(deadlineAt ?? Infinity, storeDeadline ?? Infinity);
|
|
462
407
|
return withWriterMutex(paths, { ...publishOptions, deadlineAt: bounded }, async () => {
|
|
408
|
+
for (const entry of await readOpenJournals(paths, root)) await rollForward(paths, publishOptions, entry);
|
|
463
409
|
// A function is decided here, holding the mutex, because eligibility is a
|
|
464
410
|
// statement about relationships between records and not only about each
|
|
465
411
|
// record. The generation on an entry proves that record did not change;
|
|
466
412
|
// it says nothing about a session opening for a participant this was
|
|
467
413
|
// about to remove. Deciding inside the lock is what closes that.
|
|
468
414
|
const entries = typeof plan === "function" ? await plan() : plan;
|
|
469
|
-
|
|
415
|
+
const reclaim = onCondemned => reclaimStateRecords(paths, entries,
|
|
416
|
+
{ root, limit, deadlineAt: bounded, onCondemned });
|
|
417
|
+
if (!entries.some(entry => ["message", "receipt"].includes(entry.kind))) return reclaim();
|
|
418
|
+
return withIndexedPrune(paths, { ...publishOptions, ids,
|
|
419
|
+
firstSequence: pad(await nextSequence(paths, root)), deadlineAt: bounded }, reclaim);
|
|
470
420
|
});
|
|
471
421
|
}
|
|
472
422
|
|
|
@@ -484,5 +434,6 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
484
434
|
}
|
|
485
435
|
|
|
486
436
|
return Object.freeze({ transaction, eventsSince, snapshot, stateRecord, stateEnvelopes,
|
|
487
|
-
reclaimRecords, trimHistory, ephemeral, paths, root, workspaceId
|
|
437
|
+
reclaimRecords, trimHistory, ephemeral, paths, root, workspaceId,
|
|
438
|
+
indexDiagnostics: indexCache.diagnostics });
|
|
488
439
|
}
|