narraeon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +164 -0
- package/README.zh-CN.md +164 -0
- package/dist/node/cli/NarraeonCli.js +163 -0
- package/dist/node/cli/main.js +38 -0
- package/dist/node/protocol/appPreferences.js +5 -0
- package/dist/node/protocol/contentTree.js +14 -0
- package/dist/node/protocol/modelConnections.js +110 -0
- package/dist/node/protocol/playerViews.js +1 -0
- package/dist/node/protocol/v1.js +278 -0
- package/dist/node/runtime/V1Runtime.js +625 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
- package/dist/node/runtime/config/appPaths.js +19 -0
- package/dist/node/runtime/content/ContentLimits.js +1 -0
- package/dist/node/runtime/content/ContentTreeFile.js +1 -0
- package/dist/node/runtime/content/ContentWorkspace.js +601 -0
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
- package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
- package/dist/node/runtime/model/AiFailureLog.js +332 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
- package/dist/node/runtime/model/ModelHost.js +89 -0
- package/dist/node/runtime/model/ModelHostStream.js +367 -0
- package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
- package/dist/node/runtime/model/ProviderStream.js +90 -0
- package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
- package/dist/node/runtime/play/PlayCallChain.js +1280 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
- package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
- package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
- package/dist/node/runtime/world/WorldStateRevision.js +75 -0
- package/dist/node/server/createRuntime.js +6 -0
- package/dist/node/server/createServer.js +218 -0
- package/dist/node/server/main.js +25 -0
- package/dist/node/server/startWebServer.js +28 -0
- package/dist/node/shared/default-play-prompts.js +85 -0
- package/dist/node/shared/default-preset-host.js +538 -0
- package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
- package/dist/node/shared/first-party-action-choices.js +164 -0
- package/dist/node/shared/first-party-generic-panels.js +105 -0
- package/dist/node/shared/first-party-play-preset-templates.js +41 -0
- package/dist/node/shared/first-party-player-view.js +179 -0
- package/dist/node/shared/play-preset-prompt-roles.js +4 -0
- package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
- package/dist/web/assets/index-DDx1itzT.js +1192 -0
- package/dist/web/index.html +14 -0
- package/package.json +86 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readlink, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
export class WorldOperationClaimLostError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("world operation claim was lost");
|
|
7
|
+
this.name = "WorldOperationClaimLostError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class WorldOperationBusyError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("world operation lock is busy");
|
|
13
|
+
this.name = "WorldOperationBusyError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const worldLockHeartbeatIntervalMilliseconds = 5_000;
|
|
17
|
+
const worldLockHeartbeatStaleMilliseconds = 30_000;
|
|
18
|
+
/**
|
|
19
|
+
* Coordinates current world mutations across processes.
|
|
20
|
+
*
|
|
21
|
+
* Short mutations hold a persistent world lock for their full duration.
|
|
22
|
+
* Continuity correction also owns a renewable claim while the user reviews a
|
|
23
|
+
* candidate, so another process cannot change the bound world in between.
|
|
24
|
+
*/
|
|
25
|
+
export class FileNativeWorldOperationCoordinator {
|
|
26
|
+
#correctionClaimsRoot;
|
|
27
|
+
#locksRoot;
|
|
28
|
+
#authorityLocksRoot;
|
|
29
|
+
#now;
|
|
30
|
+
#claimDurationMs;
|
|
31
|
+
#owner;
|
|
32
|
+
constructor(dataRoot, options = {}) {
|
|
33
|
+
const root = join(resolve(dataRoot), "operations");
|
|
34
|
+
this.#correctionClaimsRoot = join(root, "correction-claims");
|
|
35
|
+
this.#locksRoot = join(root, "world-locks");
|
|
36
|
+
this.#authorityLocksRoot = join(root, "authority-locks");
|
|
37
|
+
this.#now = options.now ?? Date.now;
|
|
38
|
+
this.#claimDurationMs = options.claimDurationMs ?? 60_000;
|
|
39
|
+
this.#owner = options.owner ?? randomUUID;
|
|
40
|
+
if (!Number.isSafeInteger(this.#claimDurationMs) ||
|
|
41
|
+
this.#claimDurationMs < 100)
|
|
42
|
+
throw new TypeError("world operation claim duration is invalid");
|
|
43
|
+
}
|
|
44
|
+
async claimCorrectionWorld(worldId, operationId, action) {
|
|
45
|
+
assertNonEmptyIdentity(worldId, "world ID");
|
|
46
|
+
assertNonEmptyIdentity(operationId, "correction operation ID");
|
|
47
|
+
return this.#withWorldLock(worldId, async () => {
|
|
48
|
+
if ((await this.#reconcileCorrectionClaim(worldId)) !== null)
|
|
49
|
+
return { kind: "busy" };
|
|
50
|
+
const now = this.#now();
|
|
51
|
+
const record = {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
kind: "continuity_correction_claim",
|
|
54
|
+
worldId,
|
|
55
|
+
operationId,
|
|
56
|
+
owner: this.#owner(),
|
|
57
|
+
acquiredAt: now,
|
|
58
|
+
renewedAt: now,
|
|
59
|
+
expiresAt: now + this.#claimDurationMs,
|
|
60
|
+
renewalCount: 0,
|
|
61
|
+
};
|
|
62
|
+
await publishJson(this.#correctionClaimPath(worldId), record);
|
|
63
|
+
try {
|
|
64
|
+
return {
|
|
65
|
+
kind: "claimed",
|
|
66
|
+
handle: correctionClaimHandle(record),
|
|
67
|
+
value: await action(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await this.#removeCorrectionClaim(record);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async renewCorrectionWorld(handle) {
|
|
77
|
+
assertCorrectionClaimHandle(handle);
|
|
78
|
+
await this.#withWorldLock(handle.worldId, async () => {
|
|
79
|
+
const current = await this.#readCorrectionClaim(handle.worldId);
|
|
80
|
+
if (!sameCorrectionClaimOwner(current, handle))
|
|
81
|
+
throw new WorldOperationClaimLostError();
|
|
82
|
+
if (current.expiresAt <= this.#now()) {
|
|
83
|
+
await this.#removeCorrectionClaim(current);
|
|
84
|
+
throw new WorldOperationClaimLostError();
|
|
85
|
+
}
|
|
86
|
+
const renewedAt = Math.max(current.renewedAt, this.#now());
|
|
87
|
+
await publishJson(this.#correctionClaimPath(handle.worldId), {
|
|
88
|
+
...current,
|
|
89
|
+
renewedAt,
|
|
90
|
+
expiresAt: renewedAt + this.#claimDurationMs,
|
|
91
|
+
renewalCount: current.renewalCount + 1,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async withCorrectionWorldClaim(handle, action) {
|
|
96
|
+
await this.renewCorrectionWorld(handle);
|
|
97
|
+
return action();
|
|
98
|
+
}
|
|
99
|
+
async releaseCorrectionWorld(handle) {
|
|
100
|
+
assertCorrectionClaimHandle(handle);
|
|
101
|
+
await this.#withWorldLock(handle.worldId, async () => {
|
|
102
|
+
const current = await this.#readCorrectionClaim(handle.worldId);
|
|
103
|
+
if (sameCorrectionClaimOwner(current, handle))
|
|
104
|
+
await this.#removeCorrectionClaim(current);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async withExclusiveWorldStateMutation(worldId, action) {
|
|
108
|
+
assertNonEmptyIdentity(worldId, "world ID");
|
|
109
|
+
return this.#withWorldLock(worldId, async () => {
|
|
110
|
+
if ((await this.#reconcileCorrectionClaim(worldId)) !== null)
|
|
111
|
+
throw new WorldOperationBusyError();
|
|
112
|
+
return action();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async withWorldLocalMetadataMutation(worldId, action) {
|
|
116
|
+
assertNonEmptyIdentity(worldId, "world ID");
|
|
117
|
+
return this.#withWorldLock(worldId, action);
|
|
118
|
+
}
|
|
119
|
+
async withWorldAuthorityLock(worldId, action) {
|
|
120
|
+
assertNonEmptyIdentity(worldId, "world ID");
|
|
121
|
+
return this.#withPersistentWorldLock(this.#authorityLocksRoot, worldId, 6_000, action);
|
|
122
|
+
}
|
|
123
|
+
async #readCorrectionClaim(worldId) {
|
|
124
|
+
let value;
|
|
125
|
+
try {
|
|
126
|
+
value = JSON.parse(await readFile(this.#correctionClaimPath(worldId), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
130
|
+
return null;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
if (!isCorrectionClaimRecord(value) || value.worldId !== worldId)
|
|
134
|
+
throw new Error("invalid continuity correction claim");
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
async #reconcileCorrectionClaim(worldId) {
|
|
138
|
+
const claim = await this.#readCorrectionClaim(worldId);
|
|
139
|
+
if (claim === null)
|
|
140
|
+
return null;
|
|
141
|
+
if (claim.expiresAt > this.#now())
|
|
142
|
+
return claim;
|
|
143
|
+
await this.#removeCorrectionClaim(claim);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
async #removeCorrectionClaim(claim) {
|
|
147
|
+
const current = await this.#readCorrectionClaim(claim.worldId);
|
|
148
|
+
if (current?.operationId !== claim.operationId ||
|
|
149
|
+
current.owner !== claim.owner)
|
|
150
|
+
return;
|
|
151
|
+
await rm(this.#correctionClaimPath(claim.worldId), { force: true });
|
|
152
|
+
await syncDirectory(this.#correctionClaimsRoot);
|
|
153
|
+
}
|
|
154
|
+
async #withWorldLock(worldId, action) {
|
|
155
|
+
return this.#withPersistentWorldLock(this.#locksRoot, worldId, 200, action);
|
|
156
|
+
}
|
|
157
|
+
async #withPersistentWorldLock(locksRoot, worldId, maxAttempts, action) {
|
|
158
|
+
await mkdir(locksRoot, { recursive: true, mode: 0o700 });
|
|
159
|
+
const path = join(locksRoot, digest(worldId));
|
|
160
|
+
const token = randomUUID();
|
|
161
|
+
const ownerRecord = {
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
kind: "world_operation_lock_owner",
|
|
164
|
+
token,
|
|
165
|
+
pid: process.pid,
|
|
166
|
+
processIdentity: await localProcessIdentity(),
|
|
167
|
+
};
|
|
168
|
+
let acquired = false;
|
|
169
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
170
|
+
if (await publishWorldLock(path, ownerRecord, locksRoot)) {
|
|
171
|
+
acquired = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
const owner = await readWorldLockOwner(path);
|
|
175
|
+
if (owner !== null && (await worldLockOwnerIsAlive(owner, path))) {
|
|
176
|
+
await delay(5);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const metadata = await stat(path).catch(() => null);
|
|
180
|
+
if (owner !== null ||
|
|
181
|
+
(metadata !== null && Date.now() - metadata.mtimeMs > 30_000))
|
|
182
|
+
await retireWorldLock(path, owner, metadata, locksRoot);
|
|
183
|
+
await delay(5);
|
|
184
|
+
}
|
|
185
|
+
if (!acquired)
|
|
186
|
+
throw new WorldOperationBusyError();
|
|
187
|
+
const heartbeatHandle = await open(join(path, "heartbeat"), "r+");
|
|
188
|
+
let heartbeatStopped = false;
|
|
189
|
+
let heartbeatTimer;
|
|
190
|
+
let heartbeat = Promise.resolve();
|
|
191
|
+
const scheduleHeartbeat = () => {
|
|
192
|
+
heartbeatTimer = setTimeout(() => {
|
|
193
|
+
const now = new Date();
|
|
194
|
+
heartbeat = heartbeatHandle
|
|
195
|
+
.utimes(now, now)
|
|
196
|
+
.catch(() => undefined)
|
|
197
|
+
.finally(() => {
|
|
198
|
+
if (!heartbeatStopped)
|
|
199
|
+
scheduleHeartbeat();
|
|
200
|
+
});
|
|
201
|
+
}, worldLockHeartbeatIntervalMilliseconds);
|
|
202
|
+
};
|
|
203
|
+
scheduleHeartbeat();
|
|
204
|
+
try {
|
|
205
|
+
return await action();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
heartbeatStopped = true;
|
|
209
|
+
if (heartbeatTimer !== undefined)
|
|
210
|
+
clearTimeout(heartbeatTimer);
|
|
211
|
+
await heartbeat;
|
|
212
|
+
await heartbeatHandle.close();
|
|
213
|
+
await releaseWorldLock(path, token, locksRoot);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
#correctionClaimPath(worldId) {
|
|
217
|
+
return join(this.#correctionClaimsRoot, `${digest(worldId)}.json`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function isCorrectionClaimRecord(value) {
|
|
221
|
+
return (record(value) &&
|
|
222
|
+
hasExactKeys(value, [
|
|
223
|
+
"schemaVersion",
|
|
224
|
+
"kind",
|
|
225
|
+
"worldId",
|
|
226
|
+
"operationId",
|
|
227
|
+
"owner",
|
|
228
|
+
"acquiredAt",
|
|
229
|
+
"renewedAt",
|
|
230
|
+
"expiresAt",
|
|
231
|
+
"renewalCount",
|
|
232
|
+
]) &&
|
|
233
|
+
value.schemaVersion === 1 &&
|
|
234
|
+
value.kind === "continuity_correction_claim" &&
|
|
235
|
+
nonEmptyString(value.worldId) &&
|
|
236
|
+
nonEmptyString(value.operationId) &&
|
|
237
|
+
nonEmptyString(value.owner) &&
|
|
238
|
+
finiteNumber(value.acquiredAt) &&
|
|
239
|
+
finiteNumber(value.renewedAt) &&
|
|
240
|
+
finiteNumber(value.expiresAt) &&
|
|
241
|
+
Number(value.acquiredAt) <= Number(value.renewedAt) &&
|
|
242
|
+
Number(value.renewedAt) < Number(value.expiresAt) &&
|
|
243
|
+
Number.isSafeInteger(value.renewalCount) &&
|
|
244
|
+
Number(value.renewalCount) >= 0);
|
|
245
|
+
}
|
|
246
|
+
function assertCorrectionClaimHandle(handle) {
|
|
247
|
+
assertNonEmptyIdentity(handle.worldId, "world ID");
|
|
248
|
+
assertNonEmptyIdentity(handle.operationId, "correction operation ID");
|
|
249
|
+
assertNonEmptyIdentity(handle.owner, "correction claim owner");
|
|
250
|
+
}
|
|
251
|
+
function correctionClaimHandle(record) {
|
|
252
|
+
return {
|
|
253
|
+
worldId: record.worldId,
|
|
254
|
+
operationId: record.operationId,
|
|
255
|
+
owner: record.owner,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function sameCorrectionClaimOwner(record, handle) {
|
|
259
|
+
return (record !== null &&
|
|
260
|
+
record.worldId === handle.worldId &&
|
|
261
|
+
record.operationId === handle.operationId &&
|
|
262
|
+
record.owner === handle.owner);
|
|
263
|
+
}
|
|
264
|
+
async function publishJson(path, value) {
|
|
265
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
266
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
267
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
mode: 0o600,
|
|
270
|
+
});
|
|
271
|
+
await syncFile(temporary);
|
|
272
|
+
await rename(temporary, path);
|
|
273
|
+
await syncDirectory(dirname(path));
|
|
274
|
+
}
|
|
275
|
+
async function publishWorldLock(path, owner, locksRoot) {
|
|
276
|
+
const temporary = `${path}.${owner.token}.tmp`;
|
|
277
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
278
|
+
try {
|
|
279
|
+
const ownerPath = join(temporary, "owner.json");
|
|
280
|
+
await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, {
|
|
281
|
+
encoding: "utf8",
|
|
282
|
+
mode: 0o600,
|
|
283
|
+
});
|
|
284
|
+
const heartbeatPath = join(temporary, "heartbeat");
|
|
285
|
+
await writeFile(heartbeatPath, "", { encoding: "utf8", mode: 0o600 });
|
|
286
|
+
await syncFile(ownerPath);
|
|
287
|
+
await syncFile(heartbeatPath);
|
|
288
|
+
await syncDirectory(temporary);
|
|
289
|
+
try {
|
|
290
|
+
await rename(temporary, path);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (await renameWasFencedByExistingDestination(error, path))
|
|
294
|
+
return false;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
await syncDirectory(locksRoot);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
await rm(temporary, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function retireWorldLock(path, observedOwner, observedMetadata, locksRoot) {
|
|
305
|
+
if (observedOwner === null && observedMetadata === null)
|
|
306
|
+
return;
|
|
307
|
+
const currentOwner = await readWorldLockOwner(path);
|
|
308
|
+
const currentMetadata = await stat(path).catch(() => null);
|
|
309
|
+
const sameGeneration = observedOwner === null
|
|
310
|
+
? currentOwner === null &&
|
|
311
|
+
observedMetadata !== null &&
|
|
312
|
+
currentMetadata !== null &&
|
|
313
|
+
observedMetadata.dev === currentMetadata.dev &&
|
|
314
|
+
observedMetadata.ino === currentMetadata.ino
|
|
315
|
+
: currentOwner?.token === observedOwner.token;
|
|
316
|
+
if (!sameGeneration)
|
|
317
|
+
return;
|
|
318
|
+
if (currentOwner !== null &&
|
|
319
|
+
(await worldLockOwnerIsAlive(currentOwner, path)))
|
|
320
|
+
return;
|
|
321
|
+
if (currentOwner === null &&
|
|
322
|
+
(currentMetadata === null || Date.now() - currentMetadata.mtimeMs <= 30_000))
|
|
323
|
+
return;
|
|
324
|
+
const generation = observedOwner === null
|
|
325
|
+
? `inode-${observedMetadata.dev}-${observedMetadata.ino}`
|
|
326
|
+
: observedOwner.token;
|
|
327
|
+
const retired = `${path}.${digest(generation)}.retired`;
|
|
328
|
+
try {
|
|
329
|
+
await rename(path, retired);
|
|
330
|
+
await syncDirectory(locksRoot);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
334
|
+
return;
|
|
335
|
+
if (await renameWasFencedByExistingDestination(error, retired))
|
|
336
|
+
return;
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async function releaseWorldLock(path, token, locksRoot) {
|
|
341
|
+
const owner = await readWorldLockOwner(path);
|
|
342
|
+
if (owner?.token !== token)
|
|
343
|
+
return;
|
|
344
|
+
const retired = `${path}.${digest(token)}.retired`;
|
|
345
|
+
try {
|
|
346
|
+
await rename(path, retired);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
350
|
+
return;
|
|
351
|
+
if (await renameWasFencedByExistingDestination(error, retired))
|
|
352
|
+
return;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
await syncDirectory(locksRoot);
|
|
356
|
+
}
|
|
357
|
+
async function renameWasFencedByExistingDestination(error, destination) {
|
|
358
|
+
if (!isNodeError(error))
|
|
359
|
+
return false;
|
|
360
|
+
if (error.code === "EEXIST" || error.code === "ENOTEMPTY")
|
|
361
|
+
return true;
|
|
362
|
+
if (error.code !== "EACCES" && error.code !== "EPERM")
|
|
363
|
+
return false;
|
|
364
|
+
return (await stat(destination).catch(() => null)) !== null;
|
|
365
|
+
}
|
|
366
|
+
async function readWorldLockOwner(lockPath) {
|
|
367
|
+
let value;
|
|
368
|
+
try {
|
|
369
|
+
value = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8"));
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
373
|
+
return null;
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
if (!record(value) ||
|
|
377
|
+
!hasExactKeys(value, [
|
|
378
|
+
"schemaVersion",
|
|
379
|
+
"kind",
|
|
380
|
+
"token",
|
|
381
|
+
"pid",
|
|
382
|
+
"processIdentity",
|
|
383
|
+
]) ||
|
|
384
|
+
value.schemaVersion !== 1 ||
|
|
385
|
+
value.kind !== "world_operation_lock_owner" ||
|
|
386
|
+
!nonEmptyString(value.token) ||
|
|
387
|
+
!positiveInteger(value.pid) ||
|
|
388
|
+
!nonEmptyString(value.processIdentity))
|
|
389
|
+
throw new Error("invalid world operation lock owner");
|
|
390
|
+
return {
|
|
391
|
+
schemaVersion: 1,
|
|
392
|
+
kind: "world_operation_lock_owner",
|
|
393
|
+
token: value.token,
|
|
394
|
+
pid: value.pid,
|
|
395
|
+
processIdentity: value.processIdentity,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
let cachedLocalProcessIdentity;
|
|
399
|
+
function localProcessIdentity() {
|
|
400
|
+
cachedLocalProcessIdentity ??= operatingSystemProcessIdentity(process.pid).then((identity) => identity ?? `fallback:${process.pid}:${randomUUID()}`);
|
|
401
|
+
return cachedLocalProcessIdentity;
|
|
402
|
+
}
|
|
403
|
+
async function worldLockOwnerIsAlive(owner, lockPath) {
|
|
404
|
+
const identity = await operatingSystemProcessIdentity(owner.pid);
|
|
405
|
+
if (identity === owner.processIdentity)
|
|
406
|
+
return true;
|
|
407
|
+
if (identity !== null)
|
|
408
|
+
return false;
|
|
409
|
+
try {
|
|
410
|
+
process.kill(owner.pid, 0);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (isNodeError(error) && error.code === "ESRCH")
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
const heartbeat = await stat(join(lockPath, "heartbeat")).catch(() => null);
|
|
418
|
+
return (heartbeat !== null &&
|
|
419
|
+
Date.now() - heartbeat.mtimeMs <= worldLockHeartbeatStaleMilliseconds);
|
|
420
|
+
}
|
|
421
|
+
async function operatingSystemProcessIdentity(pid) {
|
|
422
|
+
try {
|
|
423
|
+
const [bootId, namespace, processStat] = await Promise.all([
|
|
424
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
425
|
+
readlink(`/proc/${pid}/ns/pid`),
|
|
426
|
+
readFile(`/proc/${pid}/stat`, "utf8"),
|
|
427
|
+
]);
|
|
428
|
+
const commandEnd = processStat.lastIndexOf(") ");
|
|
429
|
+
if (commandEnd < 0)
|
|
430
|
+
return null;
|
|
431
|
+
const fields = processStat
|
|
432
|
+
.slice(commandEnd + 2)
|
|
433
|
+
.trim()
|
|
434
|
+
.split(/\s+/u);
|
|
435
|
+
const startTicks = fields[19];
|
|
436
|
+
if (startTicks === undefined || !/^\d+$/u.test(startTicks))
|
|
437
|
+
return null;
|
|
438
|
+
return `linux:${bootId.trim()}:${namespace}:${startTicks}`;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function assertNonEmptyIdentity(value, label) {
|
|
445
|
+
if (value.trim() === "")
|
|
446
|
+
throw new TypeError(`${label} is invalid`);
|
|
447
|
+
}
|
|
448
|
+
function nonEmptyString(value) {
|
|
449
|
+
return typeof value === "string" && value.trim() !== "";
|
|
450
|
+
}
|
|
451
|
+
function positiveInteger(value) {
|
|
452
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
453
|
+
}
|
|
454
|
+
function finiteNumber(value) {
|
|
455
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
456
|
+
}
|
|
457
|
+
function record(value) {
|
|
458
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
459
|
+
}
|
|
460
|
+
function hasExactKeys(value, keys) {
|
|
461
|
+
return (Object.keys(value).length === keys.length &&
|
|
462
|
+
keys.every((key) => Object.hasOwn(value, key)));
|
|
463
|
+
}
|
|
464
|
+
function isNodeError(error) {
|
|
465
|
+
return error instanceof Error && "code" in error;
|
|
466
|
+
}
|
|
467
|
+
function delay(milliseconds) {
|
|
468
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
|
|
469
|
+
}
|
|
470
|
+
async function syncFile(path) {
|
|
471
|
+
const handle = await open(path, "r");
|
|
472
|
+
try {
|
|
473
|
+
await handle.sync();
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
await handle.close();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async function syncDirectory(path) {
|
|
480
|
+
const handle = await open(path, "r");
|
|
481
|
+
try {
|
|
482
|
+
await handle.sync();
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
await handle.close();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function digest(value) {
|
|
489
|
+
return createHash("sha256").update(value).digest("hex");
|
|
490
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { WorldDocumentStore, } from "./WorldDocumentStore.js";
|
|
2
|
+
export function beginWorldStateRevision(files) {
|
|
3
|
+
const snapshot = WorldDocumentStore.open({
|
|
4
|
+
layout: "world_state",
|
|
5
|
+
files: Object.entries(files).map(([path, contents]) => ({
|
|
6
|
+
path,
|
|
7
|
+
contents,
|
|
8
|
+
})),
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
snapshot,
|
|
12
|
+
files: revisionFileRecord(snapshot),
|
|
13
|
+
changes: new Map(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function reviseWorldState(candidate, request) {
|
|
17
|
+
const revised = candidate.snapshot.revise(request);
|
|
18
|
+
return acceptWorldStateRevision(candidate, revised);
|
|
19
|
+
}
|
|
20
|
+
export function acceptWorldStateRevision(candidate, revised) {
|
|
21
|
+
if (!revised.ok || revised.snapshotStatus !== "usable")
|
|
22
|
+
return revised;
|
|
23
|
+
if (revised.sourceSnapshotId !== candidate.snapshot.id)
|
|
24
|
+
throw new Error("The revision result does not belong to the current candidate snapshot");
|
|
25
|
+
candidate.snapshot = revised.snapshot;
|
|
26
|
+
candidate.files = revisionFileRecord(revised.snapshot);
|
|
27
|
+
for (const change of revised.changes) {
|
|
28
|
+
const previous = candidate.changes.get(change.documentId);
|
|
29
|
+
const accumulated = {
|
|
30
|
+
...change,
|
|
31
|
+
before: previous === undefined ? change.before : previous.before,
|
|
32
|
+
};
|
|
33
|
+
if (accumulated.before !== null &&
|
|
34
|
+
accumulated.before.logicalPath === accumulated.after.logicalPath &&
|
|
35
|
+
accumulated.before.mechanicalHash === accumulated.after.mechanicalHash &&
|
|
36
|
+
accumulated.before.contents === accumulated.after.contents)
|
|
37
|
+
candidate.changes.delete(change.documentId);
|
|
38
|
+
else
|
|
39
|
+
candidate.changes.set(change.documentId, Object.freeze(accumulated));
|
|
40
|
+
}
|
|
41
|
+
return revised;
|
|
42
|
+
}
|
|
43
|
+
export function worldStateRevisionChanges(candidate) {
|
|
44
|
+
return [...candidate.changes.values()].sort((left, right) => left.after.logicalPath.localeCompare(right.after.logicalPath));
|
|
45
|
+
}
|
|
46
|
+
export function fileNativeStateChanges(candidate) {
|
|
47
|
+
if (candidate.snapshot.status !== "usable")
|
|
48
|
+
throw new Error("Only a usable world-state revision can produce a commit payload");
|
|
49
|
+
return worldStateRevisionChanges(candidate).map((change) => {
|
|
50
|
+
const prefix = "state/";
|
|
51
|
+
if (!change.after.logicalPath.startsWith(prefix))
|
|
52
|
+
throw new Error("The world-state revision returned a path outside the layout");
|
|
53
|
+
return {
|
|
54
|
+
kind: change.before === null ? "create" : "replace",
|
|
55
|
+
documentId: change.documentId,
|
|
56
|
+
stableShortRef: change.shortRef,
|
|
57
|
+
relativePath: change.after.logicalPath.slice(prefix.length),
|
|
58
|
+
codec: change.codec,
|
|
59
|
+
expectedPreviousHash: change.before?.mechanicalHash ?? null,
|
|
60
|
+
nextHash: change.after.mechanicalHash,
|
|
61
|
+
canonicalNextBytes: change.after.contents,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function worldDocumentRevisionFailureMessage(diagnostics) {
|
|
66
|
+
return (diagnostics.map(({ message }) => message).join("; ") ||
|
|
67
|
+
"The revision is invalid");
|
|
68
|
+
}
|
|
69
|
+
function revisionFileRecord(snapshot) {
|
|
70
|
+
return Object.fromEntries(snapshot.files.map((file) => {
|
|
71
|
+
if (file.encoding !== undefined)
|
|
72
|
+
throw new Error("World-state revisions do not accept binary candidate files");
|
|
73
|
+
return [file.path, file.contents];
|
|
74
|
+
}));
|
|
75
|
+
}
|