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
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, open, readFile, readlink, rename, rm, stat, writeFile, } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { worldQueueFor } from "./WorldQueue.js";
4
5
  export class WorldOperationClaimLostError extends Error {
5
6
  constructor() {
6
7
  super("world operation claim was lost");
@@ -13,104 +14,70 @@ export class WorldOperationBusyError extends Error {
13
14
  this.name = "WorldOperationBusyError";
14
15
  }
15
16
  }
16
- const worldLockHeartbeatIntervalMilliseconds = 5_000;
17
- const worldLockHeartbeatStaleMilliseconds = 30_000;
18
17
  /**
19
- * Coordinates current world mutations across processes.
18
+ * Coordinates world mutations inside the single service process.
20
19
  *
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.
20
+ * Every world-changing operation runs through the data root's `WorldQueue`,
21
+ * so no cross-process lock files exist. A continuity correction candidate
22
+ * holds an in-memory claim until it is applied, discarded, or the process
23
+ * exits. The world-revision epoch is the only persisted marker, because it
24
+ * is edit-mode state that survives restarts.
24
25
  */
25
26
  export class FileNativeWorldOperationCoordinator {
26
- #correctionClaimsRoot;
27
27
  #revisionLocksRoot;
28
- #locksRoot;
29
- #authorityLocksRoot;
30
- #now;
31
- #claimDurationMs;
28
+ #claims;
32
29
  #owner;
30
+ #queue;
33
31
  constructor(dataRoot, options = {}) {
34
32
  const root = join(resolve(dataRoot), "operations");
35
- this.#correctionClaimsRoot = join(root, "correction-claims");
36
33
  this.#revisionLocksRoot = join(root, "world-revision-locks");
37
- this.#locksRoot = join(root, "world-locks");
38
- this.#authorityLocksRoot = join(root, "authority-locks");
39
- this.#now = options.now ?? Date.now;
40
- this.#claimDurationMs = options.claimDurationMs ?? 60_000;
41
34
  this.#owner = options.owner ?? randomUUID;
42
- if (!Number.isSafeInteger(this.#claimDurationMs) ||
43
- this.#claimDurationMs < 100)
44
- throw new TypeError("world operation claim duration is invalid");
35
+ this.#queue = worldQueueFor(resolve(dataRoot));
36
+ this.#claims = claimsFor(resolve(dataRoot));
45
37
  }
46
38
  async claimCorrectionWorld(worldId, operationId, action) {
47
39
  assertNonEmptyIdentity(worldId, "world ID");
48
40
  assertNonEmptyIdentity(operationId, "correction operation ID");
49
- return this.#withWorldLock(worldId, async () => {
50
- if ((await this.#reconcileCorrectionClaim(worldId)) !== null ||
41
+ return this.#queue.run(worldId, async () => {
42
+ if (this.#claims.has(worldId) ||
51
43
  (await this.#readRevisionLock(worldId)) !== null)
52
44
  return { kind: "busy" };
53
- const now = this.#now();
54
- const record = {
55
- schemaVersion: 1,
56
- kind: "continuity_correction_claim",
45
+ const handle = {
57
46
  worldId,
58
47
  operationId,
59
48
  owner: this.#owner(),
60
- acquiredAt: now,
61
- renewedAt: now,
62
- expiresAt: now + this.#claimDurationMs,
63
- renewalCount: 0,
64
49
  };
65
- await publishJson(this.#correctionClaimPath(worldId), record);
50
+ this.#claims.set(worldId, handle);
66
51
  try {
67
- return {
68
- kind: "claimed",
69
- handle: correctionClaimHandle(record),
70
- value: await action(),
71
- };
52
+ return { kind: "claimed", handle, value: await action() };
72
53
  }
73
54
  catch (error) {
74
- await this.#removeCorrectionClaim(record);
55
+ this.#releaseClaim(handle);
75
56
  throw error;
76
57
  }
77
58
  });
78
59
  }
79
- async renewCorrectionWorld(handle) {
60
+ async withCorrectionWorldClaim(handle, action) {
80
61
  assertCorrectionClaimHandle(handle);
81
- await this.#withWorldLock(handle.worldId, async () => {
82
- const current = await this.#readCorrectionClaim(handle.worldId);
83
- if (!sameCorrectionClaimOwner(current, handle))
62
+ return this.#queue.run(handle.worldId, async () => {
63
+ const current = this.#claims.get(handle.worldId);
64
+ if (current?.operationId !== handle.operationId ||
65
+ current.owner !== handle.owner)
84
66
  throw new WorldOperationClaimLostError();
85
- if (current.expiresAt <= this.#now()) {
86
- await this.#removeCorrectionClaim(current);
87
- throw new WorldOperationClaimLostError();
88
- }
89
- const renewedAt = Math.max(current.renewedAt, this.#now());
90
- await publishJson(this.#correctionClaimPath(handle.worldId), {
91
- ...current,
92
- renewedAt,
93
- expiresAt: renewedAt + this.#claimDurationMs,
94
- renewalCount: current.renewalCount + 1,
95
- });
67
+ return action();
96
68
  });
97
69
  }
98
- async withCorrectionWorldClaim(handle, action) {
99
- await this.renewCorrectionWorld(handle);
100
- return action();
101
- }
102
70
  async releaseCorrectionWorld(handle) {
103
71
  assertCorrectionClaimHandle(handle);
104
- await this.#withWorldLock(handle.worldId, async () => {
105
- const current = await this.#readCorrectionClaim(handle.worldId);
106
- if (sameCorrectionClaimOwner(current, handle))
107
- await this.#removeCorrectionClaim(current);
72
+ await this.#queue.run(handle.worldId, () => {
73
+ this.#releaseClaim(handle);
74
+ return Promise.resolve();
108
75
  });
109
76
  }
110
77
  async withExclusiveWorldStateMutation(worldId, action) {
111
78
  assertNonEmptyIdentity(worldId, "world ID");
112
- return this.#withWorldLock(worldId, async () => {
113
- if ((await this.#reconcileCorrectionClaim(worldId)) !== null ||
79
+ return this.#queue.run(worldId, async () => {
80
+ if (this.#claims.has(worldId) ||
114
81
  (await this.#readRevisionLock(worldId)) !== null)
115
82
  throw new WorldOperationBusyError();
116
83
  return action();
@@ -123,7 +90,7 @@ export class FileNativeWorldOperationCoordinator {
123
90
  async acquireWorldRevision(worldId, epochId) {
124
91
  assertNonEmptyIdentity(worldId, "world ID");
125
92
  assertNonEmptyIdentity(epochId, "world-revision epoch ID");
126
- return this.#withWorldLock(worldId, async () => {
93
+ return this.#queue.run(worldId, async () => {
127
94
  const existing = await this.#readRevisionLock(worldId);
128
95
  if (existing !== null)
129
96
  return existing.epochId === epochId
@@ -132,14 +99,14 @@ export class FileNativeWorldOperationCoordinator {
132
99
  handle: revisionLockHandle(existing),
133
100
  }
134
101
  : { kind: "busy", epochId: existing.epochId };
135
- if ((await this.#reconcileCorrectionClaim(worldId)) !== null)
102
+ if (this.#claims.has(worldId))
136
103
  return { kind: "busy" };
137
104
  const record = {
138
105
  schemaVersion: 1,
139
106
  kind: "world_revision_lock",
140
107
  worldId,
141
108
  epochId,
142
- acquiredAt: this.#now(),
109
+ acquiredAt: Date.now(),
143
110
  };
144
111
  await publishJson(this.#revisionLockPath(worldId), record);
145
112
  return { kind: "acquired", handle: revisionLockHandle(record) };
@@ -147,17 +114,18 @@ export class FileNativeWorldOperationCoordinator {
147
114
  }
148
115
  async readWorldRevisionLock(worldId) {
149
116
  assertNonEmptyIdentity(worldId, "world ID");
150
- const record = await this.#readRevisionLock(worldId);
117
+ const record = await this.#queue.run(worldId, () => this.#readRevisionLock(worldId));
151
118
  return record === null ? null : revisionLockHandle(record);
152
119
  }
153
120
  async assertWorldRevisionUnlocked(worldId) {
154
121
  assertNonEmptyIdentity(worldId, "world ID");
155
- if ((await this.#readRevisionLock(worldId)) !== null)
122
+ const record = await this.#queue.run(worldId, () => this.#readRevisionLock(worldId));
123
+ if (record !== null)
156
124
  throw new WorldOperationBusyError();
157
125
  }
158
126
  async withWorldRevisionLock(handle, action) {
159
127
  assertWorldRevisionLockHandle(handle);
160
- return this.#withWorldLock(handle.worldId, async () => {
128
+ return this.#queue.run(handle.worldId, async () => {
161
129
  const current = await this.#readRevisionLock(handle.worldId);
162
130
  if (current?.epochId !== handle.epochId)
163
131
  throw new WorldOperationClaimLostError();
@@ -166,35 +134,31 @@ export class FileNativeWorldOperationCoordinator {
166
134
  }
167
135
  async releaseWorldRevision(handle) {
168
136
  assertWorldRevisionLockHandle(handle);
169
- await this.#withWorldLock(handle.worldId, async () => {
137
+ await this.#queue.run(handle.worldId, async () => {
170
138
  const current = await this.#readRevisionLock(handle.worldId);
171
139
  if (current?.epochId !== handle.epochId)
172
140
  return;
173
141
  await rm(this.#revisionLockPath(handle.worldId), { force: true });
174
- await syncDirectory(this.#revisionLocksRoot);
175
142
  });
176
143
  }
177
144
  async withWorldLocalMetadataMutation(worldId, action) {
178
145
  assertNonEmptyIdentity(worldId, "world ID");
179
- return this.#withWorldLock(worldId, action);
146
+ return this.#queue.run(worldId, action);
180
147
  }
148
+ /** Runs a world-changing action in the world's in-process queue. */
181
149
  async withWorldAuthorityLock(worldId, action) {
182
150
  assertNonEmptyIdentity(worldId, "world ID");
183
- return this.#withPersistentWorldLock(this.#authorityLocksRoot, worldId, 6_000, action);
151
+ return this.#queue.run(worldId, action);
184
152
  }
185
- async #readCorrectionClaim(worldId) {
186
- let value;
187
- try {
188
- value = JSON.parse(await readFile(this.#correctionClaimPath(worldId), "utf8"));
189
- }
190
- catch (error) {
191
- if (isNodeError(error) && error.code === "ENOENT")
192
- return null;
193
- throw error;
194
- }
195
- if (!isCorrectionClaimRecord(value) || value.worldId !== worldId)
196
- throw new Error("invalid continuity correction claim");
197
- return value;
153
+ /** True while the current async context already holds this world's queue slot. */
154
+ isWorldHeld(worldId) {
155
+ return this.#queue.isHeld(worldId);
156
+ }
157
+ #releaseClaim(handle) {
158
+ const current = this.#claims.get(handle.worldId);
159
+ if (current?.operationId === handle.operationId &&
160
+ current.owner === handle.owner)
161
+ this.#claims.delete(handle.worldId);
198
162
  }
199
163
  async #readRevisionLock(worldId) {
200
164
  let value;
@@ -210,92 +174,20 @@ export class FileNativeWorldOperationCoordinator {
210
174
  throw new Error("invalid world revision lock");
211
175
  return value;
212
176
  }
213
- async #reconcileCorrectionClaim(worldId) {
214
- const claim = await this.#readCorrectionClaim(worldId);
215
- if (claim === null)
216
- return null;
217
- if (claim.expiresAt > this.#now())
218
- return claim;
219
- await this.#removeCorrectionClaim(claim);
220
- return null;
221
- }
222
- async #removeCorrectionClaim(claim) {
223
- const current = await this.#readCorrectionClaim(claim.worldId);
224
- if (current?.operationId !== claim.operationId ||
225
- current.owner !== claim.owner)
226
- return;
227
- await rm(this.#correctionClaimPath(claim.worldId), { force: true });
228
- await syncDirectory(this.#correctionClaimsRoot);
229
- }
230
- async #withWorldLock(worldId, action) {
231
- return this.#withPersistentWorldLock(this.#locksRoot, worldId, 200, action);
232
- }
233
- async #withPersistentWorldLock(locksRoot, worldId, maxAttempts, action) {
234
- await mkdir(locksRoot, { recursive: true, mode: 0o700 });
235
- const path = join(locksRoot, digest(worldId));
236
- const token = randomUUID();
237
- const ownerRecord = {
238
- schemaVersion: 1,
239
- kind: "world_operation_lock_owner",
240
- token,
241
- pid: process.pid,
242
- processIdentity: await localProcessIdentity(),
243
- };
244
- let acquired = false;
245
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
246
- if (await publishWorldLock(path, ownerRecord, locksRoot)) {
247
- acquired = true;
248
- break;
249
- }
250
- const owner = await readWorldLockOwner(path);
251
- if (owner !== null && (await worldLockOwnerIsAlive(owner, path))) {
252
- await delay(5);
253
- continue;
254
- }
255
- const metadata = await stat(path).catch(() => null);
256
- if (owner !== null ||
257
- (metadata !== null && Date.now() - metadata.mtimeMs > 30_000))
258
- await retireWorldLock(path, owner, metadata, locksRoot);
259
- await delay(5);
260
- }
261
- if (!acquired)
262
- throw new WorldOperationBusyError();
263
- const heartbeatHandle = await open(join(path, "heartbeat"), "r+");
264
- let heartbeatStopped = false;
265
- let heartbeatTimer;
266
- let heartbeat = Promise.resolve();
267
- const scheduleHeartbeat = () => {
268
- heartbeatTimer = setTimeout(() => {
269
- const now = new Date();
270
- heartbeat = heartbeatHandle
271
- .utimes(now, now)
272
- .catch(() => undefined)
273
- .finally(() => {
274
- if (!heartbeatStopped)
275
- scheduleHeartbeat();
276
- });
277
- }, worldLockHeartbeatIntervalMilliseconds);
278
- };
279
- scheduleHeartbeat();
280
- try {
281
- return await action();
282
- }
283
- finally {
284
- heartbeatStopped = true;
285
- if (heartbeatTimer !== undefined)
286
- clearTimeout(heartbeatTimer);
287
- await heartbeat;
288
- await heartbeatHandle.close();
289
- await releaseWorldLock(path, token, locksRoot);
290
- }
291
- }
292
- #correctionClaimPath(worldId) {
293
- return join(this.#correctionClaimsRoot, `${digest(worldId)}.json`);
294
- }
295
177
  #revisionLockPath(worldId) {
296
178
  return join(this.#revisionLocksRoot, `${digest(worldId)}.json`);
297
179
  }
298
180
  }
181
+ const claimsByDataRoot = new Map();
182
+ /** In-memory correction claims, shared by every store under one data root. */
183
+ function claimsFor(dataRoot) {
184
+ let claims = claimsByDataRoot.get(dataRoot);
185
+ if (claims === undefined) {
186
+ claims = new Map();
187
+ claimsByDataRoot.set(dataRoot, claims);
188
+ }
189
+ return claims;
190
+ }
299
191
  function isWorldRevisionLockRecord(value) {
300
192
  return (record(value) &&
301
193
  hasExactKeys(value, [
@@ -318,50 +210,11 @@ function assertWorldRevisionLockHandle(handle) {
318
210
  function revisionLockHandle(record) {
319
211
  return { worldId: record.worldId, epochId: record.epochId };
320
212
  }
321
- function isCorrectionClaimRecord(value) {
322
- return (record(value) &&
323
- hasExactKeys(value, [
324
- "schemaVersion",
325
- "kind",
326
- "worldId",
327
- "operationId",
328
- "owner",
329
- "acquiredAt",
330
- "renewedAt",
331
- "expiresAt",
332
- "renewalCount",
333
- ]) &&
334
- value.schemaVersion === 1 &&
335
- value.kind === "continuity_correction_claim" &&
336
- nonEmptyString(value.worldId) &&
337
- nonEmptyString(value.operationId) &&
338
- nonEmptyString(value.owner) &&
339
- finiteNumber(value.acquiredAt) &&
340
- finiteNumber(value.renewedAt) &&
341
- finiteNumber(value.expiresAt) &&
342
- Number(value.acquiredAt) <= Number(value.renewedAt) &&
343
- Number(value.renewedAt) < Number(value.expiresAt) &&
344
- Number.isSafeInteger(value.renewalCount) &&
345
- Number(value.renewalCount) >= 0);
346
- }
347
213
  function assertCorrectionClaimHandle(handle) {
348
214
  assertNonEmptyIdentity(handle.worldId, "world ID");
349
215
  assertNonEmptyIdentity(handle.operationId, "correction operation ID");
350
216
  assertNonEmptyIdentity(handle.owner, "correction claim owner");
351
217
  }
352
- function correctionClaimHandle(record) {
353
- return {
354
- worldId: record.worldId,
355
- operationId: record.operationId,
356
- owner: record.owner,
357
- };
358
- }
359
- function sameCorrectionClaimOwner(record, handle) {
360
- return (record !== null &&
361
- record.worldId === handle.worldId &&
362
- record.operationId === handle.operationId &&
363
- record.owner === handle.owner);
364
- }
365
218
  async function publishJson(path, value) {
366
219
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
367
220
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -369,178 +222,7 @@ async function publishJson(path, value) {
369
222
  encoding: "utf8",
370
223
  mode: 0o600,
371
224
  });
372
- await syncFile(temporary);
373
225
  await rename(temporary, path);
374
- await syncDirectory(dirname(path));
375
- }
376
- async function publishWorldLock(path, owner, locksRoot) {
377
- const temporary = `${path}.${owner.token}.tmp`;
378
- await mkdir(temporary, { mode: 0o700 });
379
- try {
380
- const ownerPath = join(temporary, "owner.json");
381
- await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, {
382
- encoding: "utf8",
383
- mode: 0o600,
384
- });
385
- const heartbeatPath = join(temporary, "heartbeat");
386
- await writeFile(heartbeatPath, "", { encoding: "utf8", mode: 0o600 });
387
- await syncFile(ownerPath);
388
- await syncFile(heartbeatPath);
389
- await syncDirectory(temporary);
390
- try {
391
- await rename(temporary, path);
392
- }
393
- catch (error) {
394
- if (await renameWasFencedByExistingDestination(error, path))
395
- return false;
396
- throw error;
397
- }
398
- await syncDirectory(locksRoot);
399
- return true;
400
- }
401
- finally {
402
- await rm(temporary, { recursive: true, force: true });
403
- }
404
- }
405
- async function retireWorldLock(path, observedOwner, observedMetadata, locksRoot) {
406
- if (observedOwner === null && observedMetadata === null)
407
- return;
408
- const currentOwner = await readWorldLockOwner(path);
409
- const currentMetadata = await stat(path).catch(() => null);
410
- const sameGeneration = observedOwner === null
411
- ? currentOwner === null &&
412
- observedMetadata !== null &&
413
- currentMetadata !== null &&
414
- observedMetadata.dev === currentMetadata.dev &&
415
- observedMetadata.ino === currentMetadata.ino
416
- : currentOwner?.token === observedOwner.token;
417
- if (!sameGeneration)
418
- return;
419
- if (currentOwner !== null &&
420
- (await worldLockOwnerIsAlive(currentOwner, path)))
421
- return;
422
- if (currentOwner === null &&
423
- (currentMetadata === null || Date.now() - currentMetadata.mtimeMs <= 30_000))
424
- return;
425
- const generation = observedOwner === null
426
- ? `inode-${observedMetadata.dev}-${observedMetadata.ino}`
427
- : observedOwner.token;
428
- const retired = `${path}.${digest(generation)}.retired`;
429
- try {
430
- await rename(path, retired);
431
- await syncDirectory(locksRoot);
432
- }
433
- catch (error) {
434
- if (isNodeError(error) && error.code === "ENOENT")
435
- return;
436
- if (await renameWasFencedByExistingDestination(error, retired))
437
- return;
438
- throw error;
439
- }
440
- }
441
- async function releaseWorldLock(path, token, locksRoot) {
442
- const owner = await readWorldLockOwner(path);
443
- if (owner?.token !== token)
444
- return;
445
- const retired = `${path}.${digest(token)}.retired`;
446
- try {
447
- await rename(path, retired);
448
- }
449
- catch (error) {
450
- if (isNodeError(error) && error.code === "ENOENT")
451
- return;
452
- if (await renameWasFencedByExistingDestination(error, retired))
453
- return;
454
- throw error;
455
- }
456
- await syncDirectory(locksRoot);
457
- }
458
- async function renameWasFencedByExistingDestination(error, destination) {
459
- if (!isNodeError(error))
460
- return false;
461
- if (error.code === "EEXIST" || error.code === "ENOTEMPTY")
462
- return true;
463
- if (error.code !== "EACCES" && error.code !== "EPERM")
464
- return false;
465
- return (await stat(destination).catch(() => null)) !== null;
466
- }
467
- async function readWorldLockOwner(lockPath) {
468
- let value;
469
- try {
470
- value = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8"));
471
- }
472
- catch (error) {
473
- if (isNodeError(error) && error.code === "ENOENT")
474
- return null;
475
- throw error;
476
- }
477
- if (!record(value) ||
478
- !hasExactKeys(value, [
479
- "schemaVersion",
480
- "kind",
481
- "token",
482
- "pid",
483
- "processIdentity",
484
- ]) ||
485
- value.schemaVersion !== 1 ||
486
- value.kind !== "world_operation_lock_owner" ||
487
- !nonEmptyString(value.token) ||
488
- !positiveInteger(value.pid) ||
489
- !nonEmptyString(value.processIdentity))
490
- throw new Error("invalid world operation lock owner");
491
- return {
492
- schemaVersion: 1,
493
- kind: "world_operation_lock_owner",
494
- token: value.token,
495
- pid: value.pid,
496
- processIdentity: value.processIdentity,
497
- };
498
- }
499
- let cachedLocalProcessIdentity;
500
- function localProcessIdentity() {
501
- cachedLocalProcessIdentity ??= operatingSystemProcessIdentity(process.pid).then((identity) => identity ?? `fallback:${process.pid}:${randomUUID()}`);
502
- return cachedLocalProcessIdentity;
503
- }
504
- async function worldLockOwnerIsAlive(owner, lockPath) {
505
- const identity = await operatingSystemProcessIdentity(owner.pid);
506
- if (identity === owner.processIdentity)
507
- return true;
508
- if (identity !== null)
509
- return false;
510
- try {
511
- process.kill(owner.pid, 0);
512
- return true;
513
- }
514
- catch (error) {
515
- if (isNodeError(error) && error.code === "ESRCH")
516
- return false;
517
- }
518
- const heartbeat = await stat(join(lockPath, "heartbeat")).catch(() => null);
519
- return (heartbeat !== null &&
520
- Date.now() - heartbeat.mtimeMs <= worldLockHeartbeatStaleMilliseconds);
521
- }
522
- async function operatingSystemProcessIdentity(pid) {
523
- try {
524
- const [bootId, namespace, processStat] = await Promise.all([
525
- readFile("/proc/sys/kernel/random/boot_id", "utf8"),
526
- readlink(`/proc/${pid}/ns/pid`),
527
- readFile(`/proc/${pid}/stat`, "utf8"),
528
- ]);
529
- const commandEnd = processStat.lastIndexOf(") ");
530
- if (commandEnd < 0)
531
- return null;
532
- const fields = processStat
533
- .slice(commandEnd + 2)
534
- .trim()
535
- .split(/\s+/u);
536
- const startTicks = fields[19];
537
- if (startTicks === undefined || !/^\d+$/u.test(startTicks))
538
- return null;
539
- return `linux:${bootId.trim()}:${namespace}:${startTicks}`;
540
- }
541
- catch {
542
- return null;
543
- }
544
226
  }
545
227
  function assertNonEmptyIdentity(value, label) {
546
228
  if (value.trim() === "")
@@ -549,9 +231,6 @@ function assertNonEmptyIdentity(value, label) {
549
231
  function nonEmptyString(value) {
550
232
  return typeof value === "string" && value.trim() !== "";
551
233
  }
552
- function positiveInteger(value) {
553
- return Number.isSafeInteger(value) && Number(value) > 0;
554
- }
555
234
  function finiteNumber(value) {
556
235
  return typeof value === "number" && Number.isFinite(value);
557
236
  }
@@ -565,27 +244,6 @@ function hasExactKeys(value, keys) {
565
244
  function isNodeError(error) {
566
245
  return error instanceof Error && "code" in error;
567
246
  }
568
- function delay(milliseconds) {
569
- return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
570
- }
571
- async function syncFile(path) {
572
- const handle = await open(path, "r");
573
- try {
574
- await handle.sync();
575
- }
576
- finally {
577
- await handle.close();
578
- }
579
- }
580
- async function syncDirectory(path) {
581
- const handle = await open(path, "r");
582
- try {
583
- await handle.sync();
584
- }
585
- finally {
586
- await handle.close();
587
- }
588
- }
589
247
  function digest(value) {
590
248
  return createHash("sha256").update(value).digest("hex");
591
249
  }
@@ -0,0 +1,46 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { resolve } from "node:path";
3
+ /**
4
+ * Serializes world-changing operations inside one service process.
5
+ *
6
+ * One data directory is served by one process (ADR-0044), so ordering is
7
+ * enforced with promise chains instead of cross-process lock files. Re-entry
8
+ * is recognized through AsyncLocalStorage: an action already running for a
9
+ * world runs its nested world operations directly instead of waiting on
10
+ * itself.
11
+ */
12
+ export class WorldQueue {
13
+ #tails = new Map();
14
+ #held = new AsyncLocalStorage();
15
+ run(worldId, action) {
16
+ const held = this.#held.getStore();
17
+ if (held?.has(worldId))
18
+ return action();
19
+ const previous = this.#tails.get(worldId) ?? Promise.resolve();
20
+ const nextHeld = new Set(held ?? []);
21
+ nextHeld.add(worldId);
22
+ const result = previous.then(() => this.#held.run(nextHeld, action));
23
+ const tail = result.then(() => undefined, () => undefined);
24
+ this.#tails.set(worldId, tail);
25
+ void tail.then(() => {
26
+ if (this.#tails.get(worldId) === tail)
27
+ this.#tails.delete(worldId);
28
+ });
29
+ return result;
30
+ }
31
+ /** True while the current async context is inside an operation for worldId. */
32
+ isHeld(worldId) {
33
+ return this.#held.getStore()?.has(worldId) ?? false;
34
+ }
35
+ }
36
+ const queuesByDataRoot = new Map();
37
+ /** The queue shared by every store that changes a world under one data root. */
38
+ export function worldQueueFor(dataRoot) {
39
+ const key = resolve(dataRoot);
40
+ let queue = queuesByDataRoot.get(key);
41
+ if (queue === undefined) {
42
+ queue = new WorldQueue();
43
+ queuesByDataRoot.set(key, queue);
44
+ }
45
+ return queue;
46
+ }