narraeon 0.6.0 → 0.6.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 -0
- package/README.zh-CN.md +8 -0
- package/dist/node/protocol/v1.js +0 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/V1Runtime.js +1 -5
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +0 -10
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
- package/dist/node/runtime/play/PlayCallChain.js +338 -366
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +3 -15
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +0 -12
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/web/assets/{index-BgBL8G-A.js → index-7GzLSUpU.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
|
@@ -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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
@@ -5,7 +5,6 @@ import { validateMaterialList } from "../prompt/MaterialSelection.js";
|
|
|
5
5
|
import { FileNativeWorldCreationError, } from "../world/FileNativeWorldStore.js";
|
|
6
6
|
import { WorldOperationBusyError, } from "../world/WorldOperationCoordinator.js";
|
|
7
7
|
import { beginWorldStateRevision, fileNativeStateChanges, reviseWorldState, worldDocumentRevisionFailureMessage, worldStateRevisionChanges, } from "../world/WorldStateRevision.js";
|
|
8
|
-
const claimHeartbeatIntervalMilliseconds = 20_000;
|
|
9
8
|
export class FileNativeContinuityCorrection {
|
|
10
9
|
#worlds;
|
|
11
10
|
#compiler;
|
|
@@ -59,16 +58,10 @@ export class FileNativeContinuityCorrection {
|
|
|
59
58
|
}
|
|
60
59
|
if (revision.snapshot.status !== "usable") {
|
|
61
60
|
this.#worlds.releaseControl(input.worldId, input.operationId);
|
|
62
|
-
await this.#worlds.
|
|
61
|
+
await this.#worlds.releaseOperationReservation(input.operationId);
|
|
63
62
|
await this.#worlds.operations.releaseCorrectionWorld(claimed.handle);
|
|
64
63
|
throw new Error("The correction candidate's world-state snapshot is unavailable");
|
|
65
64
|
}
|
|
66
|
-
const claimHeartbeat = setInterval(() => {
|
|
67
|
-
void this.#worlds.operations
|
|
68
|
-
.renewCorrectionWorld(claimed.handle)
|
|
69
|
-
.catch(() => undefined);
|
|
70
|
-
}, claimHeartbeatIntervalMilliseconds);
|
|
71
|
-
claimHeartbeat.unref();
|
|
72
65
|
try {
|
|
73
66
|
const candidate = {
|
|
74
67
|
...revision,
|
|
@@ -87,7 +80,6 @@ export class FileNativeContinuityCorrection {
|
|
|
87
80
|
reads: new Map(),
|
|
88
81
|
previewedVersion: null,
|
|
89
82
|
stateOperationClaim: claimed.handle,
|
|
90
|
-
claimHeartbeat,
|
|
91
83
|
};
|
|
92
84
|
this.#candidates.set(candidate.id, candidate);
|
|
93
85
|
return {
|
|
@@ -97,7 +89,6 @@ export class FileNativeContinuityCorrection {
|
|
|
97
89
|
};
|
|
98
90
|
}
|
|
99
91
|
catch (error) {
|
|
100
|
-
clearInterval(claimHeartbeat);
|
|
101
92
|
this.#worlds.releaseControl(input.worldId, input.operationId);
|
|
102
93
|
await Promise.allSettled([
|
|
103
94
|
this.#worlds.releaseOperationReservation(input.operationId),
|
|
@@ -200,11 +191,10 @@ export class FileNativeContinuityCorrection {
|
|
|
200
191
|
});
|
|
201
192
|
}
|
|
202
193
|
catch (error) {
|
|
203
|
-
await this.#worlds.
|
|
194
|
+
await this.#worlds.releaseOperationReservation(candidate.operationId);
|
|
204
195
|
throw error;
|
|
205
196
|
}
|
|
206
197
|
finally {
|
|
207
|
-
clearInterval(candidate.claimHeartbeat);
|
|
208
198
|
this.#worlds.releaseControl(candidate.worldId, candidate.operationId);
|
|
209
199
|
this.#candidates.delete(candidate.id);
|
|
210
200
|
await this.#worlds.operations
|
|
@@ -214,11 +204,10 @@ export class FileNativeContinuityCorrection {
|
|
|
214
204
|
}
|
|
215
205
|
async cancel(candidateId, expectedVersion) {
|
|
216
206
|
const candidate = this.#atVersion(candidateId, expectedVersion);
|
|
217
|
-
clearInterval(candidate.claimHeartbeat);
|
|
218
207
|
this.#candidates.delete(candidateId);
|
|
219
208
|
this.#worlds.releaseControl(candidate.worldId, candidate.operationId);
|
|
220
209
|
try {
|
|
221
|
-
await this.#worlds.
|
|
210
|
+
await this.#worlds.releaseOperationReservation(candidate.operationId);
|
|
222
211
|
}
|
|
223
212
|
finally {
|
|
224
213
|
await this.#worlds.operations.releaseCorrectionWorld(candidate.stateOperationClaim);
|