narraeon 0.6.0 → 0.6.2

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.
Files changed (48) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/node/protocol/v1.js +12 -2
  4. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  5. package/dist/node/runtime/V1Runtime.js +5 -5
  6. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  7. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  8. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  9. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  10. package/dist/node/runtime/authoring/ModelToolConversation.js +25 -2
  11. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  12. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  13. package/dist/node/runtime/config/dataRootLock.js +108 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +0 -10
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
  16. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  17. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  18. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  19. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  20. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
  21. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
  22. package/dist/node/runtime/play/PlayCallChain.js +338 -366
  23. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  24. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  25. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  26. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +39 -7
  27. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  28. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +14 -6
  29. package/dist/node/runtime/setting/SettingImprovementSession.js +39 -25
  30. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  31. package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
  32. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  33. package/dist/node/runtime/world/WorldActivity.js +14 -22
  34. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  35. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  36. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  37. package/dist/node/runtime/world/WorldQueue.js +46 -0
  38. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  39. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +1 -13
  40. package/dist/node/runtime/world-revision/WorldRevisionSession.js +29 -2
  41. package/dist/node/server/mobile.js +52 -0
  42. package/dist/node/server/startWebServer.js +15 -8
  43. package/dist/web/assets/index-CXnFaYK9.js +1323 -0
  44. package/dist/web/assets/{index-BJ-1Mb0e.css → index-LPMV0pl3.css} +1 -1
  45. package/dist/web/index.html +2 -2
  46. package/package.json +2 -1
  47. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  48. package/dist/web/assets/index-BgBL8G-A.js +0 -1323
@@ -0,0 +1,260 @@
1
+ import { readdir, readFile, rm, stat, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ArtifactStoreCorruptionError, artifactSequenceFileName, assertOperation, assertRawRecord, identityHash, isMissing, isNonEmptyString, isRecord, parseArtifactJson, payloadFingerprint, rawRecordFingerprint, writeArtifactJson, } from "./ArtifactStoreFiles.js";
4
+ /** Whether this world still has artifact state in the released layout. */
5
+ export async function hasLegacyArtifactState(dataRoot, worldId) {
6
+ if (await exists(legacyWorldRoot(dataRoot, worldId)))
7
+ return true;
8
+ return ((await exists(forkSnapshotPath(dataRoot, worldId))) &&
9
+ !(await exists(forkRestoredMarker(dataRoot, worldId))));
10
+ }
11
+ /** Folds one world's released artifact state into `artifacts/`, then deletes the released copies. */
12
+ export async function migrateLegacyArtifactStore(dataRoot, worldId) {
13
+ if (!(await hasLegacyArtifactState(dataRoot, worldId)))
14
+ return;
15
+ const source = await readLegacySource(dataRoot, worldId);
16
+ const folded = foldLegacy(source);
17
+ await writeFolded(dataRoot, worldId, folded);
18
+ await rm(forkSnapshotPath(dataRoot, worldId), { force: true });
19
+ await rm(forkRestoredMarker(dataRoot, worldId), { force: true });
20
+ await rm(legacyWorldRoot(dataRoot, worldId), {
21
+ recursive: true,
22
+ force: true,
23
+ });
24
+ for (const path of source.legacyOperationPaths)
25
+ await rm(path, { force: true });
26
+ await removeEmptyLegacyRoot(dataRoot);
27
+ }
28
+ async function readLegacySource(dataRoot, worldId) {
29
+ const legacyOperationPaths = await legacyOperationPathsFor(dataRoot, worldId);
30
+ const pending = await readPendingFork(dataRoot, worldId);
31
+ if (pending !== null)
32
+ return {
33
+ records: pending.records,
34
+ events: pending.events,
35
+ operations: pending.operations,
36
+ legacyOperationPaths,
37
+ };
38
+ const worldRoot = legacyWorldRoot(dataRoot, worldId);
39
+ const operations = [];
40
+ for (const path of legacyOperationPaths)
41
+ operations.push(parseArtifactJson(await readFile(path, "utf8"), "legacy artifact operation"));
42
+ return {
43
+ records: await readJsonDirectory(join(worldRoot, "records"), "legacy artifact raw record"),
44
+ events: await readJsonDirectory(join(worldRoot, "events"), "legacy artifact projection event"),
45
+ operations,
46
+ legacyOperationPaths,
47
+ };
48
+ }
49
+ async function readPendingFork(dataRoot, worldId) {
50
+ if (await exists(forkRestoredMarker(dataRoot, worldId)))
51
+ return null;
52
+ let bytes;
53
+ try {
54
+ bytes = await readFile(forkSnapshotPath(dataRoot, worldId), "utf8");
55
+ }
56
+ catch (error) {
57
+ if (isMissing(error))
58
+ return null;
59
+ throw error;
60
+ }
61
+ const snapshot = parseArtifactJson(bytes, "legacy artifact fork");
62
+ if (!isRecord(snapshot) ||
63
+ snapshot.schemaVersion !== 1 ||
64
+ snapshot.worldId !== worldId ||
65
+ !Array.isArray(snapshot.operations) ||
66
+ !Array.isArray(snapshot.records) ||
67
+ !Array.isArray(snapshot.events))
68
+ throw new ArtifactStoreCorruptionError("Artifact fork snapshot is invalid");
69
+ for (const operation of snapshot.operations) {
70
+ assertLegacyOperation(operation);
71
+ if (operation.context.worldId !== worldId)
72
+ throw new ArtifactStoreCorruptionError("Artifact fork world mismatch");
73
+ }
74
+ return snapshot;
75
+ }
76
+ function assertLegacyOperation(operation) {
77
+ if (operation.status === "core_materialization_pending")
78
+ throw new ArtifactStoreCorruptionError("Artifact operation awaits a materialization that no longer exists");
79
+ assertOperation(operation);
80
+ }
81
+ /**
82
+ * Replays the released event log exactly as the released reader did, then
83
+ * writes each record's resulting status and head. A record's own fingerprint
84
+ * must still match its released content, so a damaged source is rejected.
85
+ */
86
+ function foldLegacy(source) {
87
+ const operations = new Map(source.operations.map((operation) => {
88
+ assertLegacyOperation(operation);
89
+ return [operation.context.operationId, operation];
90
+ }));
91
+ const raw = [...source.records].sort((a, b) => a.sequence - b.sequence);
92
+ const sequences = new Set();
93
+ const recordIds = new Set();
94
+ for (const record of raw) {
95
+ if (record.newHead !== null ||
96
+ record.status !== "pending" ||
97
+ record.recordFingerprint !== rawRecordFingerprint(record) ||
98
+ record.payloadFingerprint !==
99
+ payloadFingerprint(record.output, record.payload) ||
100
+ recordIds.has(record.recordId) ||
101
+ sequences.has(record.sequence) ||
102
+ !isNonEmptyString(record.recordId))
103
+ throw new ArtifactStoreCorruptionError("Legacy artifact raw record is corrupt");
104
+ recordIds.add(record.recordId);
105
+ sequences.add(record.sequence);
106
+ const operation = operations.get(record.operationId);
107
+ if (operation?.context.worldId !== record.worldId ||
108
+ operation.context.parentHead !== record.parentHead ||
109
+ operation.context.playPresetId !== record.playPresetId ||
110
+ operation.context.playPresetRevision !== record.playPresetRevision ||
111
+ operation.context.playPresetScriptsEnabled !==
112
+ record.playPresetScriptsEnabled)
113
+ throw new ArtifactStoreCorruptionError("Legacy artifact raw record does not match the operation identity");
114
+ }
115
+ const events = [...source.events].sort((a, b) => a.sequence - b.sequence);
116
+ for (const event of events) {
117
+ if (sequences.has(event.sequence))
118
+ throw new ArtifactStoreCorruptionError("Artifact world sequence is duplicated");
119
+ sequences.add(event.sequence);
120
+ }
121
+ const state = new Map();
122
+ for (const record of raw)
123
+ state.set(record.recordId, { status: "pending", head: null });
124
+ for (const event of events) {
125
+ const eventOperation = operations.get(event.operationId);
126
+ if (eventOperation === undefined)
127
+ throw new ArtifactStoreCorruptionError("Artifact projection-event operation does not exist");
128
+ if (event.kind === "ignored_call")
129
+ continue;
130
+ if (event.kind === "clear") {
131
+ if (eventOperation.artifactProjectionStatus === "superseded" ||
132
+ (eventOperation.requestAttempts[event.requestId] !== undefined &&
133
+ eventOperation.requestAttempts[event.requestId] !==
134
+ event.requestAttempt))
135
+ continue;
136
+ for (const record of raw) {
137
+ const current = state.get(record.recordId);
138
+ if (current.status !== "superseded" &&
139
+ current.status !== "cleared" &&
140
+ record.sequence < event.sequence &&
141
+ record.channel === event.channel &&
142
+ record.output === event.output &&
143
+ (event.key === undefined || record.key === event.key))
144
+ current.status = "cleared";
145
+ }
146
+ continue;
147
+ }
148
+ const record = raw.find(({ recordId }) => recordId === event.recordId);
149
+ if (record?.operationId !== event.operationId)
150
+ throw new ArtifactStoreCorruptionError("Artifact projection event does not match the record identity");
151
+ const current = state.get(record.recordId);
152
+ if (event.kind === "activate") {
153
+ if (current.status === "pending")
154
+ current.status = "active";
155
+ }
156
+ else if (event.kind === "supersede") {
157
+ current.status = "superseded";
158
+ }
159
+ else {
160
+ if ((eventOperation.status !== "core_committed" &&
161
+ eventOperation.status !== "completed") ||
162
+ eventOperation.head !== event.head)
163
+ throw new ArtifactStoreCorruptionError("Artifact bind_head event does not match the operation core head");
164
+ current.head = event.head;
165
+ }
166
+ }
167
+ const records = raw.map((record) => {
168
+ const current = state.get(record.recordId);
169
+ const operation = operations.get(record.operationId);
170
+ const currentAttempt = operation?.requestAttempts[record.requestId];
171
+ const superseded = (currentAttempt !== undefined &&
172
+ currentAttempt !== record.requestAttempt) ||
173
+ operation?.artifactProjectionStatus === "superseded";
174
+ const next = {
175
+ ...record,
176
+ status: superseded ? "superseded" : current.status,
177
+ head: current.head,
178
+ };
179
+ delete next.newHead;
180
+ const folded = { ...next, recordFingerprint: rawRecordFingerprint(next) };
181
+ assertRawRecord(folded);
182
+ return folded;
183
+ });
184
+ return {
185
+ records,
186
+ operations: [...operations.values()],
187
+ };
188
+ }
189
+ async function writeFolded(dataRoot, worldId, folded) {
190
+ const artifacts = join(worldsRoot(dataRoot), worldId, "artifacts");
191
+ await rm(join(artifacts, "records"), { recursive: true, force: true });
192
+ await rm(join(artifacts, "operations"), { recursive: true, force: true });
193
+ await mkdir(artifacts, { recursive: true, mode: 0o700 });
194
+ for (const record of folded.records)
195
+ await writeArtifactJson(join(artifacts, "records", artifactSequenceFileName(record.sequence)), record);
196
+ for (const operation of folded.operations) {
197
+ assertOperation(operation);
198
+ await writeArtifactJson(join(artifacts, "operations", `${identityHash(operation.context.operationId)}.json`), operation);
199
+ }
200
+ }
201
+ async function removeEmptyLegacyRoot(dataRoot) {
202
+ const root = join(dataRoot, "artifact-store");
203
+ if ((await entries(join(root, "worlds"))).length === 0 &&
204
+ (await entries(join(root, "operations"))).length === 0)
205
+ await rm(root, { recursive: true, force: true });
206
+ }
207
+ async function legacyOperationPathsFor(dataRoot, worldId) {
208
+ const directory = join(dataRoot, "artifact-store", "operations");
209
+ const paths = [];
210
+ for (const name of await entries(directory)) {
211
+ if (!name.endsWith(".json"))
212
+ continue;
213
+ const path = join(directory, name);
214
+ const operation = parseArtifactJson(await readFile(path, "utf8"), "legacy artifact operation");
215
+ if (operation.context?.worldId === worldId)
216
+ paths.push(path);
217
+ }
218
+ return paths;
219
+ }
220
+ async function readJsonDirectory(directory, label) {
221
+ const values = [];
222
+ for (const name of (await entries(directory))
223
+ .filter((item) => item.endsWith(".json"))
224
+ .sort())
225
+ values.push(parseArtifactJson(await readFile(join(directory, name), "utf8"), label));
226
+ return values;
227
+ }
228
+ async function entries(directory) {
229
+ try {
230
+ return await readdir(directory);
231
+ }
232
+ catch (error) {
233
+ if (isMissing(error))
234
+ return [];
235
+ throw error;
236
+ }
237
+ }
238
+ async function exists(path) {
239
+ try {
240
+ await stat(path);
241
+ return true;
242
+ }
243
+ catch (error) {
244
+ if (isMissing(error))
245
+ return false;
246
+ throw error;
247
+ }
248
+ }
249
+ function worldsRoot(dataRoot) {
250
+ return join(dataRoot, "worlds-file-native");
251
+ }
252
+ function legacyWorldRoot(dataRoot, worldId) {
253
+ return join(dataRoot, "artifact-store", "worlds", identityHash(worldId));
254
+ }
255
+ function forkSnapshotPath(dataRoot, worldId) {
256
+ return join(worldsRoot(dataRoot), worldId, "runtime", "artifact-fork.json");
257
+ }
258
+ function forkRestoredMarker(dataRoot, worldId) {
259
+ return join(worldsRoot(dataRoot), worldId, "fork-restored.json");
260
+ }
@@ -119,8 +119,15 @@ export class ModelToolConversation {
119
119
  await input.settleToolResponse(session, session.modelItems.length - 1, toolCalls);
120
120
  input.changed?.(true);
121
121
  }
122
- throw new Error(input.failure?.exchangeLimit ??
123
- `Authoring exceeded ${maximumExchangesPerMessage} model exchanges for one user message`);
122
+ // The exchange budget only bounds a runaway loop. Every tool response is
123
+ // already settled, so the conversation pauses and can resume unchanged.
124
+ session.runStatus = "paused";
125
+ session.activeRequestId = null;
126
+ session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
127
+ session.lastFailure = null;
128
+ session.updatedAt = Date.now();
129
+ await input.save(session);
130
+ return input.view(session);
124
131
  }
125
132
  catch (error) {
126
133
  session.runStatus = "interrupted";
@@ -154,6 +161,22 @@ export function appendAuthoringUserMessage(session, message, requestId) {
154
161
  session.lastFailure = null;
155
162
  session.updatedAt = now;
156
163
  }
164
+ /**
165
+ * Starts another exchange budget for a paused conversation without appending a
166
+ * user message; the next request replays the settled transcript as-is. The run
167
+ * still belongs to the paused user request, so that request stays active; the
168
+ * caller's resume request ID only deduplicates the resume itself.
169
+ */
170
+ export function resumePausedAuthoringConversation(session) {
171
+ if (session.runStatus !== "paused")
172
+ throw new Error("Only a paused authoring conversation can be continued");
173
+ if (session.modelItems.at(-1)?.kind !== "tool")
174
+ throw new Error("The paused authoring conversation does not end with settled tool results");
175
+ session.runStatus = "running";
176
+ session.activeRequestId = session.requests?.at(-1)?.requestId ?? null;
177
+ session.lastFailure = null;
178
+ session.updatedAt = Date.now();
179
+ }
157
180
  export function requiredAuthoringMessage(message) {
158
181
  if (message.trim().length === 0)
159
182
  throw new Error("An authoring message cannot be empty");
@@ -71,7 +71,6 @@ export class AppPreferencesStore {
71
71
  try {
72
72
  handle = await open(temporary, "wx", 0o600);
73
73
  await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, "utf8");
74
- await handle.sync();
75
74
  await handle.close();
76
75
  handle = undefined;
77
76
  await chmod(temporary, 0o600);
@@ -86,7 +86,6 @@ export class ProtagonistTemplateStore {
86
86
  try {
87
87
  handle = await open(temporary, "wx", 0o600);
88
88
  await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...view }, null, 2)}\n`, "utf8");
89
- await handle.sync();
90
89
  await handle.close();
91
90
  handle = undefined;
92
91
  await rename(temporary, this.#path);
@@ -0,0 +1,108 @@
1
+ import { mkdir, open, readdir, readFile, rm, rmdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ /**
4
+ * One service process owns a data directory (ADR-0044). The lock is a pid
5
+ * file: a live owner refuses startup, and a dead owner's file is replaced.
6
+ */
7
+ const lockFileName = "server.pid";
8
+ /** Persistent lock directories left by the retired cross-process world locks. */
9
+ const legacyLockPaths = [
10
+ "runtime-locks",
11
+ join("operations", "world-locks"),
12
+ join("operations", "authority-locks"),
13
+ join("operations", "correction-claims"),
14
+ ];
15
+ export class DataRootBusyError extends Error {
16
+ ownerPid;
17
+ constructor(ownerPid) {
18
+ super(`Another Narraeon server (pid ${ownerPid}) is already using this data directory`);
19
+ this.name = "DataRootBusyError";
20
+ this.ownerPid = ownerPid;
21
+ }
22
+ }
23
+ export async function acquireDataRootLock(dataRoot) {
24
+ const root = resolve(dataRoot);
25
+ await mkdir(root, { recursive: true, mode: 0o700 });
26
+ const path = join(root, lockFileName);
27
+ for (let attempt = 0; attempt < 3; attempt += 1) {
28
+ try {
29
+ const handle = await open(path, "wx", 0o600);
30
+ try {
31
+ await handle.writeFile(`${process.pid}\n`, "utf8");
32
+ }
33
+ finally {
34
+ await handle.close();
35
+ }
36
+ await removeLegacyLocks(root);
37
+ return {
38
+ release: async () => {
39
+ if ((await readOwnerPid(path)) === process.pid)
40
+ await rm(path, { force: true });
41
+ },
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (!isNodeError(error) || error.code !== "EEXIST")
46
+ throw error;
47
+ const owner = await readOwnerPid(path);
48
+ if (owner !== null && processIsAlive(owner))
49
+ throw new DataRootBusyError(owner);
50
+ await rm(path, { force: true });
51
+ }
52
+ }
53
+ throw new Error("Could not acquire the data-directory startup lock");
54
+ }
55
+ async function readOwnerPid(path) {
56
+ let text;
57
+ try {
58
+ text = await readFile(path, "utf8");
59
+ }
60
+ catch (error) {
61
+ if (isNodeError(error) && error.code === "ENOENT")
62
+ return null;
63
+ throw error;
64
+ }
65
+ const pid = Number(text.trim());
66
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
67
+ }
68
+ function processIsAlive(pid) {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ return !(isNodeError(error) && error.code === "ESRCH");
75
+ }
76
+ }
77
+ async function removeLegacyLocks(root) {
78
+ for (const relative of legacyLockPaths)
79
+ await rm(join(root, relative), { recursive: true, force: true });
80
+ await removeLegacyOperationReceipts(root);
81
+ }
82
+ /**
83
+ * Operation receipts written before #76 (including archive import receipts) have
84
+ * no readers. The world-revision epoch markers in the same directory are kept.
85
+ */
86
+ async function removeLegacyOperationReceipts(root) {
87
+ const directory = join(root, "operations");
88
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
89
+ if (isNodeError(error) && error.code === "ENOENT")
90
+ return null;
91
+ throw error;
92
+ });
93
+ if (entries === null)
94
+ return;
95
+ for (const entry of entries)
96
+ if (entry.isFile() && entry.name.endsWith(".json"))
97
+ await rm(join(directory, entry.name), { force: true });
98
+ try {
99
+ await rmdir(directory);
100
+ }
101
+ catch (error) {
102
+ if (!isNodeError(error) || error.code !== "ENOTEMPTY")
103
+ throw error;
104
+ }
105
+ }
106
+ function isNodeError(error) {
107
+ return error instanceof Error && "code" in error;
108
+ }
@@ -115,7 +115,6 @@ export class ContentWorkspace {
115
115
  await this.#beforePortableContentExport?.();
116
116
  options.signal?.throwIfAborted();
117
117
  const archive = createPortableContentArchive(package_.files);
118
- await crashProcessAtPortableContentExportEdge("after_archive_created");
119
118
  options.signal?.throwIfAborted();
120
119
  return {
121
120
  fileName: `${portableArchiveBaseName(package_.title)}.zip`,
@@ -633,12 +632,3 @@ function isAbortError(error) {
633
632
  function compareStrings(left, right) {
634
633
  return left < right ? -1 : left > right ? 1 : 0;
635
634
  }
636
- async function crashProcessAtPortableContentExportEdge(edge) {
637
- if (process.env.NODE_ENV !== "test" ||
638
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PORTABLE_CONTENT_EXPORT_EDGE !==
639
- edge) {
640
- return;
641
- }
642
- process.kill(process.pid, "SIGKILL");
643
- await new Promise(() => undefined);
644
- }
@@ -1,7 +1,8 @@
1
1
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { readFileSync, unlinkSync } from "node:fs";
4
- import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
4
+ import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
+ import { linkExclusive } from "../FileNativeExclusiveLink.js";
5
6
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
6
7
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
7
8
  import { assertSameGameplayMode, gameplayFile, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
@@ -92,7 +93,6 @@ export class CurrentTreeContentLibrary {
92
93
  try {
93
94
  options.signal?.throwIfAborted();
94
95
  await rename(this.#packageRoot(localId), tombstonePath);
95
- await syncDirectory(this.#packagesRoot);
96
96
  }
97
97
  catch (error) {
98
98
  if (isAbortError(error))
@@ -267,11 +267,10 @@ export class CurrentTreeContentLibrary {
267
267
  try {
268
268
  handle = await open(temporaryPath, "wx", 0o600);
269
269
  await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
270
- await handle.sync();
271
270
  await handle.close();
272
271
  handle = undefined;
273
272
  try {
274
- await link(temporaryPath, lockPath);
273
+ await linkExclusive(temporaryPath, lockPath);
275
274
  }
276
275
  catch (error) {
277
276
  if (!isNodeErrorCode(error, "EEXIST"))
@@ -332,7 +331,6 @@ export class CurrentTreeContentLibrary {
332
331
  schemaVersion: 1,
333
332
  files: normalized,
334
333
  }));
335
- await crashProcessAtCurrentTreeEdge("after_current_tree_staging");
336
334
  signal?.throwIfAborted();
337
335
  await this.#beforeCurrentTreeReplace?.();
338
336
  signal?.throwIfAborted();
@@ -340,12 +338,8 @@ export class CurrentTreeContentLibrary {
340
338
  if (!isNodeErrorCode(error, "ENOENT"))
341
339
  throw error;
342
340
  });
343
- await link(join(packageRoot, currentTreeFileName), previousPath);
344
- await syncDirectory(packageRoot);
341
+ await linkExclusive(join(packageRoot, currentTreeFileName), previousPath);
345
342
  await rename(temporaryPath, join(packageRoot, currentTreeFileName));
346
- await syncDirectory(packageRoot);
347
- await crashProcessAtCurrentTreeEdge("after_current_tree_replace");
348
- failCurrentTreeAfterReplaceForTest();
349
343
  await unlink(previousPath).catch(() => undefined);
350
344
  }
351
345
  catch (error) {
@@ -354,7 +348,6 @@ export class CurrentTreeContentLibrary {
354
348
  if (await pathExists(previousPath).catch(() => false)) {
355
349
  try {
356
350
  await rename(previousPath, join(packageRoot, currentTreeFileName));
357
- await syncDirectory(packageRoot);
358
351
  }
359
352
  catch (rollbackError) {
360
353
  rollbackFailure = rollbackError;
@@ -394,14 +387,10 @@ export class CurrentTreeContentLibrary {
394
387
  schemaVersion: 1,
395
388
  files: normalized,
396
389
  }));
397
- await syncDirectory(stagingPath);
398
- await crashProcessAtCurrentTreeEdge("after_package_staging");
399
390
  signal?.throwIfAborted();
400
391
  await this.#beforePackagePublish?.();
401
392
  signal?.throwIfAborted();
402
393
  await rename(stagingPath, finalPath);
403
- await syncDirectory(this.#packagesRoot);
404
- await crashProcessAtCurrentTreeEdge("after_package_publish");
405
394
  }
406
395
  catch (error) {
407
396
  await rm(stagingPath, { force: true, recursive: true }).catch(() => undefined);
@@ -435,10 +424,9 @@ export class CurrentTreeContentLibrary {
435
424
  try {
436
425
  handle = await open(temporaryPath, "wx", 0o600);
437
426
  await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
438
- await handle.sync();
439
427
  await handle.close();
440
428
  handle = undefined;
441
- await link(temporaryPath, reclaimPath);
429
+ await linkExclusive(temporaryPath, reclaimPath);
442
430
  }
443
431
  catch (error) {
444
432
  await handle?.close().catch(() => undefined);
@@ -458,7 +446,7 @@ export class CurrentTreeContentLibrary {
458
446
  if (!isNodeErrorCode(error, "ENOENT"))
459
447
  throw error;
460
448
  });
461
- await link(replacementPath, lockPath);
449
+ await linkExclusive(replacementPath, lockPath);
462
450
  return true;
463
451
  }
464
452
  finally {
@@ -471,7 +459,7 @@ export class CurrentTreeContentLibrary {
471
459
  async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
472
460
  for (let attempt = 0; attempt < 2_000; attempt += 1) {
473
461
  if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
474
- (await link(replacementPath, lockPath)
462
+ (await linkExclusive(replacementPath, lockPath)
475
463
  .then(() => true)
476
464
  .catch((error) => {
477
465
  if (isNodeErrorCode(error, "EEXIST"))
@@ -663,7 +651,6 @@ async function writeStoredLocalMetadata(packageRoot, metadata) {
663
651
  try {
664
652
  await writeDurableNewFile(temporaryPath, serialize(metadata));
665
653
  await rename(temporaryPath, join(packageRoot, localMetadataFileName));
666
- await syncDirectory(packageRoot);
667
654
  }
668
655
  catch (error) {
669
656
  await rm(temporaryPath, { force: true }).catch(() => undefined);
@@ -675,16 +662,6 @@ async function writeDurableNewFile(path, contents) {
675
662
  const handle = await open(path, "wx", 0o600);
676
663
  try {
677
664
  await handle.writeFile(contents, "utf8");
678
- await handle.sync();
679
- }
680
- finally {
681
- await handle.close();
682
- }
683
- }
684
- async function syncDirectory(path) {
685
- const handle = await open(path, "r");
686
- try {
687
- await handle.sync();
688
665
  }
689
666
  finally {
690
667
  await handle.close();
@@ -825,20 +802,6 @@ function isNodeErrorCode(error, code) {
825
802
  function isAbortError(error) {
826
803
  return error instanceof Error && error.name === "AbortError";
827
804
  }
828
- async function crashProcessAtCurrentTreeEdge(edge) {
829
- if (process.env.NODE_ENV !== "test" ||
830
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_CURRENT_TREE_EDGE !== edge) {
831
- return;
832
- }
833
- process.kill(process.pid, "SIGKILL");
834
- await new Promise(() => undefined);
835
- }
836
- function failCurrentTreeAfterReplaceForTest() {
837
- if (process.env.NODE_ENV === "test" &&
838
- process.env.NARRAEON_INTERNAL_TEST_FAIL_AFTER_CURRENT_TREE_REPLACE === "1") {
839
- throw new Error("simulated failure after current tree replace");
840
- }
841
- }
842
805
  function compareStrings(left, right) {
843
806
  return left < right ? -1 : left > right ? 1 : 0;
844
807
  }
@@ -32,19 +32,11 @@ export class PackageScriptPermissions {
32
32
  const file = await open(temporary, "wx", 0o600);
33
33
  try {
34
34
  await file.writeFile(JSON.stringify({ schemaVersion: 1, grants }));
35
- await file.sync();
36
35
  }
37
36
  finally {
38
37
  await file.close();
39
38
  }
40
39
  await rename(temporary, path);
41
- const directory = await open(ownerRoot, "r");
42
- try {
43
- await directory.sync();
44
- }
45
- finally {
46
- await directory.close();
47
- }
48
40
  }
49
41
  finally {
50
42
  await rm(temporary, { force: true });
@@ -27,19 +27,11 @@ export class WorldExtensionControls {
27
27
  const file = await open(temporary, "wx", 0o600);
28
28
  try {
29
29
  await file.writeFile(JSON.stringify(state));
30
- await file.sync();
31
30
  }
32
31
  finally {
33
32
  await file.close();
34
33
  }
35
34
  await rename(temporary, path);
36
- const directory = await open(root, "r");
37
- try {
38
- await directory.sync();
39
- }
40
- finally {
41
- await directory.close();
42
- }
43
35
  }
44
36
  finally {
45
37
  await rm(temporary, { force: true });
@@ -220,7 +220,6 @@ export class ModelConnectionStore {
220
220
  try {
221
221
  handle = await open(temporary, "wx", 0o600);
222
222
  await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, "utf8");
223
- await handle.sync();
224
223
  await handle.close();
225
224
  handle = undefined;
226
225
  await chmod(temporary, 0o600);