narraeon 0.5.7 → 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 +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- 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 +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- 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 +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- 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/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- 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 +2 -12
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- package/dist/web/assets/index-DzOnu9uv.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir,
|
|
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
|
|
18
|
+
* Coordinates world mutations inside the single service process.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
#
|
|
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
|
-
|
|
43
|
-
|
|
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.#
|
|
50
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
this.#releaseClaim(handle);
|
|
75
56
|
throw error;
|
|
76
57
|
}
|
|
77
58
|
});
|
|
78
59
|
}
|
|
79
|
-
async
|
|
60
|
+
async withCorrectionWorldClaim(handle, action) {
|
|
80
61
|
assertCorrectionClaimHandle(handle);
|
|
81
|
-
|
|
82
|
-
const current =
|
|
83
|
-
if (
|
|
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
|
-
|
|
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.#
|
|
105
|
-
|
|
106
|
-
|
|
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.#
|
|
113
|
-
if (
|
|
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.#
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
151
|
+
return this.#queue.run(worldId, action);
|
|
184
152
|
}
|
|
185
|
-
async
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|