agents-can-communicate 0.8.5 → 0.9.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 +11 -7
- package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
- package/bin/entrypoints/acc-hook.mjs +33 -19
- package/bin/entrypoints/hook-output.mjs +14 -5
- package/docs/ADAPTER_AUTHORING.md +29 -0
- package/docs/CAPABILITIES.md +7 -6
- package/docs/CLI.md +5 -0
- package/docs/CONFIGURATION.md +41 -10
- package/docs/DESIGN_DECISIONS.md +0 -2
- package/docs/GETTING_STARTED.md +39 -6
- package/docs/HOW_IT_WORKS.md +13 -8
- package/docs/SECURITY_MODEL.md +32 -5
- package/docs/TROUBLESHOOTING.md +76 -8
- package/docs/UPGRADING.md +47 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
- package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -13
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
- 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 +3 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
- package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +27 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
- package/node_modules/@agents-can-communicate/cli/package.json +2 -1
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
- package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
- package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
- package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
- 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/client-pid.mjs +65 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
- package/node_modules/@agents-can-communicate/installer/package.json +3 -2
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
- package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
- 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/config.mjs +6 -2
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
- package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
- package/package.json +1 -5
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { link, open, readdir
|
|
2
|
+
import { link, open, readdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
6
|
|
|
7
7
|
import { assertPublicationDeadline } from "./deadline.mjs";
|
|
8
|
+
import { isWindows, renameReplacing, syncEntry } from "./portable-fs.mjs";
|
|
8
9
|
import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
|
|
9
10
|
import { readRegularNoFollow } from "./safe-file.mjs";
|
|
10
11
|
|
|
11
|
-
async function syncDirectory(directory) {
|
|
12
|
-
const handle = await open(directory, "r");
|
|
13
|
-
try {
|
|
14
|
-
await handle.sync();
|
|
15
|
-
} finally {
|
|
16
|
-
await handle.close();
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
12
|
export function encode(value) {
|
|
21
13
|
const serialised = JSON.stringify(value, null, 2);
|
|
22
14
|
if (serialised === undefined) {
|
|
@@ -41,6 +33,20 @@ function retainedStage(destination, root, stageDir) {
|
|
|
41
33
|
return path.join(stageDir, `${identity}.published`);
|
|
42
34
|
}
|
|
43
35
|
|
|
36
|
+
const DURABILITY = new Set(["full", "bytes", "none"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The durability of a publication that another flush follows.
|
|
40
|
+
*
|
|
41
|
+
* On Windows flushing a file commits NTFS's metadata journal up to that file's
|
|
42
|
+
* last change, and that journal is one sequence: the next flush commits this
|
|
43
|
+
* rename too, so only the bytes are flushed here. A crash before that flush
|
|
44
|
+
* leaves the previous version, or nothing, and nothing written after it can be
|
|
45
|
+
* durable without it (docs/design/2026-09-30-native-windows-support.md, "What
|
|
46
|
+
* is flushed"). POSIX syncs the name's own directory.
|
|
47
|
+
*/
|
|
48
|
+
export const nameCommittedLater = platform => (isWindows(platform) ? "bytes" : "full");
|
|
49
|
+
|
|
44
50
|
async function replaceHandleBytes(handle, bytes) {
|
|
45
51
|
await handle.truncate(0);
|
|
46
52
|
let offset = 0;
|
|
@@ -61,11 +67,22 @@ async function replaceHandleBytes(handle, bytes) {
|
|
|
61
67
|
* state is mutable by design, which is what generations exist for, so it is
|
|
62
68
|
* published by rename(). Conflating the two makes every state update fail.
|
|
63
69
|
*
|
|
70
|
+
* `durability` is what a crash of the machine may cost the record, since every
|
|
71
|
+
* flush waits for the whole disk - on windows-latest from 15 ms to 5.8 s:
|
|
72
|
+
* - "full" (the default): nothing; its bytes are flushed before they take the
|
|
73
|
+
* name, and the name is flushed before this returns;
|
|
74
|
+
* - "bytes": the name, so a reader finds the previous version and never torn
|
|
75
|
+
* bytes - for a record whose loss its owner recovers from;
|
|
76
|
+
* - "none": everything - for a record whose reader treats damage as absence.
|
|
77
|
+
*
|
|
64
78
|
* @returns {Promise<"published" | "already_published">}
|
|
65
79
|
*/
|
|
66
80
|
export async function publishAtomic(destination, bytes,
|
|
67
|
-
{ root, tmpDir, stageDir, replace = false, deadlineAt, afterAccepted,
|
|
68
|
-
afterStageRenamed }) {
|
|
81
|
+
{ root, tmpDir, stageDir, replace = false, durability = "full", deadlineAt, afterAccepted,
|
|
82
|
+
afterStageEnsured, afterStageRenamed, sync = syncEntry }) {
|
|
83
|
+
if (!DURABILITY.has(durability)) {
|
|
84
|
+
throw new AccError(EXIT.USAGE, "unknown publication durability", { durability });
|
|
85
|
+
}
|
|
69
86
|
assertPublicationDeadline(deadlineAt);
|
|
70
87
|
// The accepted stage lives apart from the partial a failed publication
|
|
71
88
|
// leaves, so what a file is follows from the directory it was created in
|
|
@@ -101,13 +118,16 @@ export async function publishAtomic(destination, bytes,
|
|
|
101
118
|
let stageAcceptedBytes = false;
|
|
102
119
|
try {
|
|
103
120
|
await handle.writeFile(bytes);
|
|
104
|
-
await handle.sync();
|
|
121
|
+
if (durability !== "none") await handle.sync();
|
|
105
122
|
if (replace) {
|
|
106
123
|
await handle.close();
|
|
107
124
|
handle = null;
|
|
108
125
|
assertPublicationDeadline(deadlineAt);
|
|
109
|
-
await
|
|
110
|
-
|
|
126
|
+
await renameReplacing(temporary, destination, { deadlineAt });
|
|
127
|
+
// The bytes are at their name now: the write is decided and visible, and
|
|
128
|
+
// its flush is bounded by its own wait, never by the caller's deadline,
|
|
129
|
+
// which would report a published record as failed.
|
|
130
|
+
if (durability === "full") await sync(destinationDir, destination);
|
|
111
131
|
return "published";
|
|
112
132
|
}
|
|
113
133
|
|
|
@@ -115,7 +135,7 @@ export async function publishAtomic(destination, bytes,
|
|
|
115
135
|
assertPublicationDeadline(deadlineAt);
|
|
116
136
|
await link(temporary, destination);
|
|
117
137
|
stageAcceptedBytes = true;
|
|
118
|
-
await
|
|
138
|
+
if (durability === "full") await sync(destinationDir, destination);
|
|
119
139
|
return "published";
|
|
120
140
|
} catch (error) {
|
|
121
141
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -160,9 +180,10 @@ export async function publishAtomic(destination, bytes,
|
|
|
160
180
|
// The sweep can take `stage` between any two of these steps: it renames the
|
|
161
181
|
// directory aside, and a store still opening publishes here without the writer
|
|
162
182
|
// mutex the sweep holds (CI on #217). The bytes are already published, so the
|
|
163
|
-
// retained copy goes to whichever directory carries the name when it moves
|
|
164
|
-
//
|
|
165
|
-
//
|
|
183
|
+
// retained copy goes to whichever directory carries the name when it moves. The
|
|
184
|
+
// copy is never flushed: it is never read, and the sweep discards it. A crash
|
|
185
|
+
// that loses its move leaves the temporary in tmp, which is what tmp holds. The
|
|
186
|
+
// two callbacks are seams for the race tests.
|
|
166
187
|
const STAGE_RETRIES = 4;
|
|
167
188
|
|
|
168
189
|
async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStageEnsured,
|
|
@@ -171,16 +192,13 @@ async function retainAcceptedStage({ root, stageDir, temporary, stage, afterStag
|
|
|
171
192
|
await ensureManagedDirectory(root, stageDir);
|
|
172
193
|
await afterStageEnsured?.();
|
|
173
194
|
try {
|
|
174
|
-
await
|
|
195
|
+
await renameReplacing(temporary, stage);
|
|
175
196
|
break;
|
|
176
197
|
} catch (error) {
|
|
177
198
|
if (error.code !== "ENOENT" || attempt >= STAGE_RETRIES) throw error;
|
|
178
199
|
}
|
|
179
200
|
}
|
|
180
201
|
await afterStageRenamed?.();
|
|
181
|
-
await syncDirectory(stageDir).catch(error => {
|
|
182
|
-
if (error.code !== "ENOENT") throw error;
|
|
183
|
-
});
|
|
184
202
|
}
|
|
185
203
|
|
|
186
204
|
// The opener is the seam the race tests use, and stays last so callers that do
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { rmdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { listDirectoryEntries } from "./atomic-json.mjs";
|
|
6
|
+
import { removeTree, renameEntry } from "./portable-fs.mjs";
|
|
6
7
|
import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
|
|
7
8
|
|
|
8
9
|
// The store never unlinks a live name: Node exposes unlink only by pathname, so
|
|
@@ -21,15 +22,6 @@ const DOOMED = "stage.sweeping-";
|
|
|
21
22
|
// is reported as remaining, and the next pass adopts it.
|
|
22
23
|
export const expired = deadlineAt => deadlineAt !== undefined && Date.now() >= deadlineAt;
|
|
23
24
|
|
|
24
|
-
export async function syncDirectory(directory) {
|
|
25
|
-
const handle = await open(directory, "r");
|
|
26
|
-
try {
|
|
27
|
-
await handle.sync();
|
|
28
|
-
} finally {
|
|
29
|
-
await handle.close();
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
export async function doomedDirectories(root) {
|
|
34
26
|
return (await listDirectoryEntries(root, { root }))
|
|
35
27
|
.filter(entry => entry.isDirectory() && entry.name.startsWith(DOOMED))
|
|
@@ -56,7 +48,7 @@ export async function detachDoomed(root) {
|
|
|
56
48
|
* discarded whole, so the names inside it carry no meaning.
|
|
57
49
|
*/
|
|
58
50
|
export async function condemn(filePath, doomed) {
|
|
59
|
-
await
|
|
51
|
+
await renameEntry(filePath, path.join(doomed, `${randomUUID()}.doomed`));
|
|
60
52
|
}
|
|
61
53
|
|
|
62
54
|
/**
|
|
@@ -69,7 +61,7 @@ export async function discard(directory, root, budget, deadlineAt) {
|
|
|
69
61
|
await assertManagedDirectory(root, directory);
|
|
70
62
|
for (const entry of await listDirectoryEntries(directory, { root })) {
|
|
71
63
|
if (spent >= budget || expired(deadlineAt)) return { spent, drained: false };
|
|
72
|
-
await
|
|
64
|
+
await removeTree(path.join(directory, entry.name));
|
|
73
65
|
spent += 1;
|
|
74
66
|
}
|
|
75
67
|
await rmdir(directory).catch(error => {
|
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
4
|
|
|
5
|
-
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
5
|
+
import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
6
|
|
|
7
7
|
export const STORE_VERSION = 6;
|
|
8
8
|
|
|
@@ -29,7 +29,8 @@ function assertIdentity(record, workspaceId, filePath) {
|
|
|
29
29
|
* foreign store is the failure the reconciled prototype fails closed on, and
|
|
30
30
|
* the same rule applies here.
|
|
31
31
|
*/
|
|
32
|
-
export async function requireStoreIdentity(paths, { workspaceId, clock, create = true
|
|
32
|
+
export async function requireStoreIdentity(paths, { workspaceId, clock, create = true,
|
|
33
|
+
platform }) {
|
|
33
34
|
assertPortableId(workspaceId, "workspace id");
|
|
34
35
|
const filePath = identityPath(paths);
|
|
35
36
|
const found = await readJsonIfPresent(filePath, paths.root);
|
|
@@ -39,7 +40,10 @@ export async function requireStoreIdentity(paths, { workspaceId, clock, create =
|
|
|
39
40
|
}
|
|
40
41
|
const record = { storeVersion: STORE_VERSION, workspaceId, initialisedAt: clock.now() };
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
+
// The journal's first pointer, published next, commits this name on
|
|
44
|
+
// Windows; a crash before it leaves a directory the next open initialises.
|
|
45
|
+
await publishAtomic(filePath, encode(record), { root: paths.root, tmpDir: paths.tmp,
|
|
46
|
+
durability: nameCommittedLater(platform) });
|
|
43
47
|
} catch (error) {
|
|
44
48
|
// Losing this race is not a failure. Two agents starting together in a
|
|
45
49
|
// workspace neither has opened before is the ordinary case, and the two
|
|
@@ -4,3 +4,4 @@ export { diagnoseFilesystemStore, repairFilesystemStore } from "./recovery.mjs";
|
|
|
4
4
|
export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identity.mjs";
|
|
5
5
|
export { withWriterMutex } from "./writer-mutex.mjs";
|
|
6
6
|
export { readSessionRecord } from "./session-read.mjs";
|
|
7
|
+
export { openNoFollow } from "./portable-fs.mjs";
|
|
@@ -3,12 +3,27 @@ import path from "node:path";
|
|
|
3
3
|
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
4
|
|
|
5
5
|
import { activateJournal, idleJournal, readActiveJournal } from "./active-journal.mjs";
|
|
6
|
-
import { encode, publishAtomic, readJsonIfPresent, retainFile }
|
|
6
|
+
import { encode, nameCommittedLater, publishAtomic, readJsonIfPresent, retainFile }
|
|
7
7
|
from "./atomic-json.mjs";
|
|
8
|
-
import { completeJournal } from "./retention.mjs";
|
|
9
8
|
|
|
10
9
|
export const JOURNAL_VERSION = 2;
|
|
11
10
|
|
|
11
|
+
// What a publication inside a transaction leaves to the flush after it.
|
|
12
|
+
//
|
|
13
|
+
// The entry's name is made durable by the pointer that opens it, which is
|
|
14
|
+
// published beside it in journal/: POSIX syncs that directory, and on Windows
|
|
15
|
+
// flushing a file commits NTFS's metadata journal up to that file's last change
|
|
16
|
+
// (docs/design/2026-09-30-native-windows-support.md, "What is flushed"). That
|
|
17
|
+
// journal is one sequence, so on Windows every rename logged before a flush is
|
|
18
|
+
// committed with it: each record's own flush commits the rename before it, and
|
|
19
|
+
// the pointer going idle commits the last record's. Only bytes are flushed
|
|
20
|
+
// there. A crash that loses the idle pointer's rename leaves the transaction
|
|
21
|
+
// open, and nothing written after it can be durable without that rename, so
|
|
22
|
+
// rolling it forward again changes nothing. A flush took 15 ms at the median and
|
|
23
|
+
// up to 5.8 s on windows-latest beside the suite (2026-10-01).
|
|
24
|
+
const ENTRY = "bytes";
|
|
25
|
+
const linked = nameCommittedLater;
|
|
26
|
+
|
|
12
27
|
// A journal entry is prepared only after the transaction callback has
|
|
13
28
|
// succeeded and every byte is known. The subsequent synced active-log record
|
|
14
29
|
// is the commit point: an orphan prepared journal is not a decided
|
|
@@ -62,17 +77,19 @@ export function journalEntry(transactionId, firstSequence, publications, started
|
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
export async function writeJournalEntry(paths, options, entry) {
|
|
65
|
-
await publishAtomic(journalPath(paths, entry.transactionId), encode(entry),
|
|
80
|
+
await publishAtomic(journalPath(paths, entry.transactionId), encode(entry),
|
|
81
|
+
{ ...options, durability: ENTRY });
|
|
66
82
|
await options.failAt?.("after-journal-prepared");
|
|
67
|
-
await activateJournal(paths, options, entry);
|
|
83
|
+
await activateJournal(paths, { ...options, durability: linked(options.platform) }, entry);
|
|
68
84
|
return entry;
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
// The entry stays where it is; the reclaimer retires every entry the pointer
|
|
88
|
+
// does not name.
|
|
71
89
|
export async function retireJournalEntry(paths, options, transactionId) {
|
|
72
90
|
await retainFile(journalPath(paths, transactionId), { root: paths.root });
|
|
73
|
-
await completeJournal(paths, options, transactionId);
|
|
74
91
|
await options.failAt?.("before-journal-idle");
|
|
75
|
-
await idleJournal(paths, options, transactionId);
|
|
92
|
+
await idleJournal(paths, { ...options, durability: linked(options.platform) }, transactionId);
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
export async function readOpenJournals(paths, root) {
|
|
@@ -124,7 +141,7 @@ export async function rollForward(paths, options, entry) {
|
|
|
124
141
|
}
|
|
125
142
|
const bytes = Buffer.from(publication.bytes, "base64");
|
|
126
143
|
const outcome = await publishAtomic(destination, bytes,
|
|
127
|
-
{ ...options, replace: publication.replace === true });
|
|
144
|
+
{ ...options, replace: publication.replace === true, durability: linked(options.platform) });
|
|
128
145
|
if (outcome === "published") published.push(publication.path);
|
|
129
146
|
// Seam for the crash-window tests: abort between two publications of the
|
|
130
147
|
// same decided transaction.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Filesystem calls whose meaning differs on Windows, each measured on a
|
|
2
|
+
// windows-latest runner (docs/design/2026-09-30-native-windows-support.md).
|
|
3
|
+
// POSIX keeps the single call it always made; the Windows branch keeps the rule
|
|
4
|
+
// the caller relies on. Node built-ins only: the managed runtime's launchers
|
|
5
|
+
// carry a byte-identical copy of this file, and they may import nothing else.
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
8
|
+
|
|
9
|
+
const BUSY = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
10
|
+
// Long enough for a reader, an antivirus scan or an indexer to let go; short
|
|
11
|
+
// enough that a hook's budget is spent on the write, not on waiting.
|
|
12
|
+
const DEFAULT_WAIT_MS = 2_000;
|
|
13
|
+
const BACKOFF_MS = [5, 10, 20, 40, 80];
|
|
14
|
+
const STEADY_MS = 100;
|
|
15
|
+
|
|
16
|
+
const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
|
|
17
|
+
|
|
18
|
+
export const isWindows = (platform = process.platform) => platform === "win32";
|
|
19
|
+
|
|
20
|
+
async function retrying(operation, retry, { deadlineAt, sleep = sleepFor }) {
|
|
21
|
+
const deadline = deadlineAt ?? Date.now() + DEFAULT_WAIT_MS;
|
|
22
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
23
|
+
try {
|
|
24
|
+
return await operation();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const remaining = deadline - Date.now();
|
|
27
|
+
if (remaining <= 0 || !await retry(error)) throw error;
|
|
28
|
+
await sleep(Math.min(BACKOFF_MS[attempt] ?? STEADY_MS, remaining));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rename a file over an existing one.
|
|
35
|
+
*
|
|
36
|
+
* Windows refuses the rename while any handle is open on the target - an ACC
|
|
37
|
+
* reader, an antivirus scan, an indexer - whatever sharing that handle allows.
|
|
38
|
+
* The handle is always short-lived, so the rename is retried until the caller's
|
|
39
|
+
* deadline rather than failing a write whose bytes are ready.
|
|
40
|
+
*/
|
|
41
|
+
export async function renameReplacing(from, to, { platform = process.platform,
|
|
42
|
+
rename = fs.rename, deadlineAt, sleep } = {}) {
|
|
43
|
+
if (!isWindows(platform)) return rename(from, to);
|
|
44
|
+
return retrying(() => rename(from, to), error => BUSY.has(error.code), { deadlineAt, sleep });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rename a directory, or move any entry to a name nobody else uses.
|
|
49
|
+
*
|
|
50
|
+
* POSIX reports an occupied name as EEXIST or ENOTEMPTY, and callers treat that
|
|
51
|
+
* as "somebody else got there first". Windows reports it as EPERM, the same code
|
|
52
|
+
* it uses when a file inside the source is open. The target tells them apart:
|
|
53
|
+
* if it exists the answer is EEXIST, as on POSIX; if not, the source was busy
|
|
54
|
+
* and the rename is retried.
|
|
55
|
+
*/
|
|
56
|
+
export async function renameEntry(from, to, { platform = process.platform,
|
|
57
|
+
rename = fs.rename, lstat = fs.lstat, deadlineAt, sleep } = {}) {
|
|
58
|
+
if (!isWindows(platform)) return rename(from, to);
|
|
59
|
+
return retrying(async () => {
|
|
60
|
+
try {
|
|
61
|
+
return await rename(from, to);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!BUSY.has(error.code)) throw error;
|
|
64
|
+
const occupied = await lstat(to).then(() => true, missing => {
|
|
65
|
+
if (missing.code === "ENOENT") return false;
|
|
66
|
+
throw missing;
|
|
67
|
+
});
|
|
68
|
+
if (occupied) {
|
|
69
|
+
throw Object.assign(new Error(`EEXIST: file already exists, rename '${from}' -> '${to}'`),
|
|
70
|
+
{ code: "EEXIST", syscall: "rename", path: from, dest: to });
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}, error => BUSY.has(error.code), { deadlineAt, sleep });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const gone = (file, lstat) => lstat(file).then(() => false,
|
|
78
|
+
error => error.code === "ENOENT");
|
|
79
|
+
|
|
80
|
+
const refusedLink = file => Object.assign(
|
|
81
|
+
new Error(`ELOOP: too many symbolic links encountered, open '${file}'`),
|
|
82
|
+
{ code: "ELOOP", syscall: "open", path: file });
|
|
83
|
+
|
|
84
|
+
// How many times a name that keeps changing hands is opened afresh.
|
|
85
|
+
const SWAPS = 3;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Open a file without following a symlink at its final component.
|
|
89
|
+
*
|
|
90
|
+
* Windows has no O_NOFOLLOW: the flag is undefined there and an open through a
|
|
91
|
+
* file symlink succeeds. The rule is kept by checking the name first, refusing a
|
|
92
|
+
* symlink or junction (Node reports both as symbolic links), and after the open
|
|
93
|
+
* checking the name again: it must be no link, and name the very file the handle
|
|
94
|
+
* holds. A name another process replaced in between - a lock changing hands, a
|
|
95
|
+
* record renamed over - then names what was opened, as POSIX's O_NOFOLLOW open
|
|
96
|
+
* would have taken it; a name that moved on again is opened afresh, a few times.
|
|
97
|
+
*/
|
|
98
|
+
export async function openNoFollow(file, flags, { mode, platform = process.platform,
|
|
99
|
+
open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
|
|
100
|
+
if (!isWindows(platform)) return open(file, flags | constants.O_NOFOLLOW, mode);
|
|
101
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
102
|
+
const before = await lstat(file, { bigint: true }).catch(error => {
|
|
103
|
+
if (error.code === "ENOENT") return null;
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
if (before?.isSymbolicLink()) throw refusedLink(file);
|
|
107
|
+
// A scanner that opened the file without read sharing refuses every reader
|
|
108
|
+
// with EBUSY until it lets go.
|
|
109
|
+
const handle = await retrying(() => open(file, flags, mode), error => error.code === "EBUSY",
|
|
110
|
+
{ deadlineAt, sleep }).catch(async error => {
|
|
111
|
+
// A name another process is deleting, or a lock changing hands around
|
|
112
|
+
// it, refuses the open with EPERM, and the name is gone right after
|
|
113
|
+
// (measured on windows-latest, for a name present before the open and
|
|
114
|
+
// for one absent before it): that is ENOENT, as Linux says it. A name
|
|
115
|
+
// that is still there keeps its EPERM, and so does a create on a name
|
|
116
|
+
// that was free, which is a refused create.
|
|
117
|
+
const refusedCreate = before === null && (flags & constants.O_CREAT) !== 0;
|
|
118
|
+
if (error.code !== "EPERM" || refusedCreate || !await gone(file, lstat)) throw error;
|
|
119
|
+
throw Object.assign(new Error(`ENOENT: no such file or directory, open '${file}'`,
|
|
120
|
+
{ cause: error }), { code: "ENOENT", syscall: "open", path: file });
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
const opened = await handle.stat({ bigint: true });
|
|
124
|
+
const named = await lstat(file, { bigint: true }).catch(error => {
|
|
125
|
+
if (error.code === "ENOENT") return null;
|
|
126
|
+
throw error;
|
|
127
|
+
});
|
|
128
|
+
if (named?.isSymbolicLink()) throw refusedLink(file);
|
|
129
|
+
if (named !== null && named.dev === opened.dev && named.ino === opened.ino) return handle;
|
|
130
|
+
if (attempt >= SWAPS) throw refusedLink(file);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await handle.close();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
await handle.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Make a rename or link that produced `entry` inside `directory` durable.
|
|
141
|
+
*
|
|
142
|
+
* POSIX syncs the directory. Windows refuses a flush on a directory handle, and
|
|
143
|
+
* NTFS journals the rename as metadata: flushing the renamed file commits that
|
|
144
|
+
* journal up to the file's last change, which includes the rename. A directory
|
|
145
|
+
* entry (a lock taken by renaming a directory) guards live processes only and is
|
|
146
|
+
* not flushed.
|
|
147
|
+
*/
|
|
148
|
+
export async function syncEntry(directory, entry, { platform = process.platform,
|
|
149
|
+
open = fs.open, lstat = fs.lstat, deadlineAt, sleep } = {}) {
|
|
150
|
+
if (!isWindows(platform)) {
|
|
151
|
+
const handle = await open(directory, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
152
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if ((await lstat(entry)).isDirectory()) return;
|
|
156
|
+
const handle = await retrying(() => open(entry, "r+"), error => error.code === "EBUSY",
|
|
157
|
+
{ deadlineAt, sleep });
|
|
158
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remove a tree; Windows retries a file another process still holds. */
|
|
162
|
+
export async function removeTree(target, { platform = process.platform, rm = fs.rm } = {}) {
|
|
163
|
+
await rm(target, { recursive: true, force: true,
|
|
164
|
+
maxRetries: isWindows(platform) ? 10 : 0, retryDelay: 50 });
|
|
165
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
// A journal entry names each file it publishes relative to the store root, with
|
|
4
|
+
// forward slashes on every platform. path.relative gives backslashes on Windows,
|
|
5
|
+
// and an entry would then read differently depending on where it was written.
|
|
6
|
+
export function publicationPath(root, file, pathApi = path) {
|
|
7
|
+
return pathApi.relative(root, file).split(pathApi.sep).join("/");
|
|
8
|
+
}
|
|
@@ -17,6 +17,13 @@ const jsonNames = async (directory, root) =>
|
|
|
17
17
|
.filter(entry => entry.isFile() && entry.name.endsWith(".json"))
|
|
18
18
|
.map(entry => entry.name);
|
|
19
19
|
|
|
20
|
+
// A store that never had an older ACC write a completion marker has no
|
|
21
|
+
// directory for them.
|
|
22
|
+
const jsonNamesIfPresent = (directory, root) => jsonNames(directory, root).catch(error => {
|
|
23
|
+
if (error.code === "ENOENT") return [];
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* Which transaction the store is in the middle of, or null when it cannot tell.
|
|
22
29
|
*
|
|
@@ -40,16 +47,19 @@ async function activeTransaction(paths, root) {
|
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
|
-
* Condemn every journal entry
|
|
50
|
+
* Condemn every journal entry but the one the pointer names open.
|
|
44
51
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
52
|
+
* Every pass runs under the writer lock, as every transaction does, so none is
|
|
53
|
+
* between preparing its entry and opening it: an entry the pointer does not
|
|
54
|
+
* name either finished or was never decided, and recovery reads only the one it
|
|
55
|
+
* names. That entry stays even when a marker says it completed - an older ACC
|
|
56
|
+
* wrote its marker before the pointer went idle, and a crash between the two
|
|
57
|
+
* leaves exactly that. A completion marker is all an older ACC knew to judge
|
|
58
|
+
* by; this one writes none, and the old ones leave with or without their entry.
|
|
49
59
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
60
|
+
* Both directories are listed once rather than read per entry. A workspace in
|
|
61
|
+
* daily use reaches thousands of retired transactions, and one open each would
|
|
62
|
+
* be the cost this pass exists to remove.
|
|
53
63
|
*/
|
|
54
64
|
async function condemnRetiredJournals(paths, root, doomed, budget, deadlineAt) {
|
|
55
65
|
let spent = 0;
|
|
@@ -57,16 +67,15 @@ async function condemnRetiredJournals(paths, root, doomed, budget, deadlineAt) {
|
|
|
57
67
|
if (!active.known) return { spent, drained: true };
|
|
58
68
|
|
|
59
69
|
const markerDirectory = path.join(paths.retained, "journal");
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!completed.has(name)) continue;
|
|
64
|
-
// The marker is written before the pointer goes idle, so a crash between
|
|
65
|
-
// the two leaves a completed marker on the entry recovery still owns.
|
|
70
|
+
const markers = new Set(await jsonNamesIfPresent(markerDirectory, root));
|
|
71
|
+
const entries = new Set(await jsonNames(paths.journal, root));
|
|
72
|
+
for (const name of new Set([...entries, ...markers])) {
|
|
66
73
|
if (path.basename(name, ".json") === active.transactionId) continue;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
spent
|
|
74
|
+
const found = [...entries.has(name) ? [path.join(paths.journal, name)] : [],
|
|
75
|
+
...markers.has(name) ? [path.join(markerDirectory, name)] : []];
|
|
76
|
+
if (spent + found.length > budget || expired(deadlineAt)) return { spent, drained: false };
|
|
77
|
+
for (const file of found) await condemn(file, doomed);
|
|
78
|
+
spent += found.length;
|
|
70
79
|
}
|
|
71
80
|
return { spent, drained: true };
|
|
72
81
|
}
|
|
@@ -5,6 +5,7 @@ import { AccError, EXIT, validateRecord } from "@agents-can-communicate/protocol
|
|
|
5
5
|
import { listDirectoryEntries, listJsonFiles, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
6
|
import { readEventFloor } from "./event-floor.mjs";
|
|
7
7
|
import { readStoreIdentity } from "./identity.mjs";
|
|
8
|
+
import { readActiveJournal } from "./active-journal.mjs";
|
|
8
9
|
import { readOpenJournals, rollForward } from "./journal.mjs";
|
|
9
10
|
import { assertEventBinding, assertStateBinding } from "./record-id.mjs";
|
|
10
11
|
import { reclaimRetired } from "./reclaim.mjs";
|
|
@@ -45,12 +46,13 @@ async function countStaging(paths, root) {
|
|
|
45
46
|
const staged = (await entries(paths.stage)).filter(entry => entry.isFile()
|
|
46
47
|
&& accepted(entry.name)).length;
|
|
47
48
|
const inTmp = await entries(paths.tmp);
|
|
48
|
-
// A retired journal entry is one the store keeps and never reads again
|
|
49
|
-
// counting them is what tells an operator
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
// A retired journal entry is one the store keeps and never reads again - any
|
|
50
|
+
// the pointer does not name open - so counting them is what tells an operator
|
|
51
|
+
// the automatic pass has work left.
|
|
52
|
+
const open = await readActiveJournal(paths, root)
|
|
53
|
+
.then(active => (active.state === "open" ? `${active.transactionId}.json` : null), () => null);
|
|
52
54
|
const retired = (await entries(paths.journal))
|
|
53
|
-
.filter(entry => entry.isFile() &&
|
|
55
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(".json") && entry.name !== open).length;
|
|
54
56
|
return {
|
|
55
57
|
staged: staged + inTmp.filter(entry => accepted(entry.name)).length,
|
|
56
58
|
partials: inTmp.filter(entry => entry.name.endsWith(".tmp")).length,
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
4
|
|
|
5
5
|
import { encode, listJsonFiles, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
|
+
import { publicationPath } from "./publication-path.mjs";
|
|
6
7
|
|
|
7
8
|
const RETENTION_VERSION = 1;
|
|
8
9
|
const SEQUENCE_WIDTH = 16;
|
|
@@ -40,8 +41,8 @@ function stateMarker(paths, kind, id, generation) {
|
|
|
40
41
|
|
|
41
42
|
export function stateDeletionPublication(paths, root, kind, id, generation, retainedPath) {
|
|
42
43
|
const marker = stateMarker(paths, kind, id, generation);
|
|
43
|
-
return { path:
|
|
44
|
-
replace: false, retainedPath:
|
|
44
|
+
return { path: publicationPath(root, marker.filePath), bytes: encode(marker.record),
|
|
45
|
+
replace: false, retainedPath: publicationPath(root, retainedPath) };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export async function stateGenerationIsDeleted(paths, root, kind, id, generation) {
|
|
@@ -99,8 +100,10 @@ export async function markEphemeral(paths, options, kind, id, state) {
|
|
|
99
100
|
const previous = await latestEphemeralMarker(paths, options.root, kind, id);
|
|
100
101
|
// A marker that repeats the current state changes nothing, and every renewal
|
|
101
102
|
// of a live record used to append one. History now grows only with a real
|
|
102
|
-
// change: published after a deletion, or deleted.
|
|
103
|
-
|
|
103
|
+
// change: published after a deletion, or deleted. A record with no marker is
|
|
104
|
+
// present, so its first publication is no change either - it cost every new
|
|
105
|
+
// session an atomic write, three flushes on Windows, per ephemeral record.
|
|
106
|
+
if (previous?.state === state || (previous === null && state === "present")) return previous;
|
|
104
107
|
const sequence = pad(previous === null ? 1n : BigInt(previous.sequence) + 1n);
|
|
105
108
|
const record = { retentionVersion: RETENTION_VERSION, area: "ephemeral", kind, id,
|
|
106
109
|
sequence, state };
|
|
@@ -108,15 +111,3 @@ export async function markEphemeral(paths, options, kind, id, state) {
|
|
|
108
111
|
await publishAtomic(filePath, encode(record), options);
|
|
109
112
|
return record;
|
|
110
113
|
}
|
|
111
|
-
|
|
112
|
-
function completionMarker(paths, transactionId) {
|
|
113
|
-
assertPortableId(transactionId, "transaction id");
|
|
114
|
-
const record = { retentionVersion: RETENTION_VERSION, transactionId };
|
|
115
|
-
return { record, filePath: path.join(paths.retained, "journal", `${transactionId}.json`) };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function completeJournal(paths, options, transactionId) {
|
|
119
|
-
const marker = completionMarker(paths, transactionId);
|
|
120
|
-
await publishAtomic(marker.filePath, encode(marker.record), options);
|
|
121
|
-
return marker.record;
|
|
122
|
-
}
|