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
|
@@ -27,6 +27,7 @@ import { SettingImprovementRollbackError, SettingImprovementSession, } from "./s
|
|
|
27
27
|
import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
|
|
28
28
|
import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
|
|
29
29
|
import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
|
|
30
|
+
import { FileNativeRuntimeErrorLog } from "./RuntimeErrorLog.js";
|
|
30
31
|
import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
|
|
31
32
|
import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
|
|
32
33
|
import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
|
|
@@ -44,6 +45,7 @@ export class V1Runtime {
|
|
|
44
45
|
#corrections;
|
|
45
46
|
#playCallChains;
|
|
46
47
|
#failureLog;
|
|
48
|
+
#errorLog;
|
|
47
49
|
#preferences;
|
|
48
50
|
#protagonistTemplates;
|
|
49
51
|
#settingImprovements;
|
|
@@ -65,6 +67,10 @@ export class V1Runtime {
|
|
|
65
67
|
input.logRoot === undefined
|
|
66
68
|
? undefined
|
|
67
69
|
: new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
|
|
70
|
+
this.#errorLog =
|
|
71
|
+
input.logRoot === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: new FileNativeRuntimeErrorLog(join(input.logRoot, "runtime-errors"));
|
|
68
74
|
this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
|
|
69
75
|
this.worldArchives = new WorldArchives({
|
|
70
76
|
dataRoot: input.dataRoot,
|
|
@@ -150,9 +156,11 @@ export class V1Runtime {
|
|
|
150
156
|
async handle(request, playCallChainObserver) {
|
|
151
157
|
try {
|
|
152
158
|
const result = await this.#dispatch(request, playCallChainObserver);
|
|
159
|
+
await this.#recordPlayInterruption(request, result);
|
|
153
160
|
return { protocol: v1Protocol, result };
|
|
154
161
|
}
|
|
155
162
|
catch (error) {
|
|
163
|
+
await this.#errorLog?.recordRequestFailure(request, error);
|
|
156
164
|
if (error instanceof WorldArchiveError)
|
|
157
165
|
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
158
166
|
if (request.type === "world.archive.export" &&
|
|
@@ -176,6 +184,32 @@ export class V1Runtime {
|
|
|
176
184
|
}
|
|
177
185
|
}
|
|
178
186
|
}
|
|
187
|
+
async #recordPlayInterruption(request, result) {
|
|
188
|
+
if (this.#errorLog === undefined ||
|
|
189
|
+
(request.type !== "play.chain.start" &&
|
|
190
|
+
request.type !== "play.chain.append"))
|
|
191
|
+
return;
|
|
192
|
+
const chain = result;
|
|
193
|
+
if (chain?.status !== "interrupted" ||
|
|
194
|
+
typeof chain.lastFailure !== "string" ||
|
|
195
|
+
chain.events?.at(-1)?.kind === "cancellation")
|
|
196
|
+
return;
|
|
197
|
+
await this.#errorLog.recordPlayInterruption(request, {
|
|
198
|
+
message: chain.lastFailure,
|
|
199
|
+
...(chain.lastFailureKind === undefined
|
|
200
|
+
? {}
|
|
201
|
+
: { kind: chain.lastFailureKind }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async recordConversationFailure(target, error) {
|
|
205
|
+
await this.#errorLog?.recordRequestFailure({
|
|
206
|
+
type: `${target.kind}.observe`,
|
|
207
|
+
targetId: target.id,
|
|
208
|
+
...("sessionId" in target && target.sessionId !== undefined
|
|
209
|
+
? { sessionId: target.sessionId }
|
|
210
|
+
: {}),
|
|
211
|
+
}, error);
|
|
212
|
+
}
|
|
179
213
|
subscribeConversation(target, changed) {
|
|
180
214
|
return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
|
|
181
215
|
}
|
|
@@ -258,7 +292,19 @@ export class V1Runtime {
|
|
|
258
292
|
case "model.models":
|
|
259
293
|
return this.#models.listModels(request);
|
|
260
294
|
case "content.create":
|
|
261
|
-
return this.#content.createCurrentTreeContentPackage();
|
|
295
|
+
return this.#content.createCurrentTreeContentPackage(request.gameplay);
|
|
296
|
+
case "content.gameplay.set": {
|
|
297
|
+
try {
|
|
298
|
+
return await this.#content.setCurrentTreeContentGameplayScheme(request.packageId, request.scheme);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof InvalidContentTreeError)
|
|
302
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
303
|
+
cause: error,
|
|
304
|
+
});
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
262
308
|
case "content.create-sample":
|
|
263
309
|
return this.#content.createSampleContentPackage(request.locale);
|
|
264
310
|
case "content.scripts.read":
|
|
@@ -272,7 +318,16 @@ export class V1Runtime {
|
|
|
272
318
|
case "content.read":
|
|
273
319
|
return this.#content.readCurrentTreeContentPackage(request.packageId);
|
|
274
320
|
case "content.replace":
|
|
275
|
-
|
|
321
|
+
try {
|
|
322
|
+
return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error instanceof InvalidContentTreeError)
|
|
326
|
+
throw new V1ProtocolError("invalid_request", error.message, {
|
|
327
|
+
cause: error,
|
|
328
|
+
});
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
276
331
|
case "content.copy":
|
|
277
332
|
return this.#content.copyCurrentTreeContentPackage(request.packageId);
|
|
278
333
|
case "content.delete":
|
|
@@ -441,6 +496,7 @@ export class V1Runtime {
|
|
|
441
496
|
case "world.delete":
|
|
442
497
|
return playCall(async () => {
|
|
443
498
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
499
|
+
await this.#playCallChains.abortWorld(request.worldId);
|
|
444
500
|
this.#playCallChains.forgetWorld(request.worldId);
|
|
445
501
|
return this.#worlds.deleteWorld(request.worldId);
|
|
446
502
|
});
|
|
@@ -521,11 +577,6 @@ export class V1Runtime {
|
|
|
521
577
|
case "artifacts.debug":
|
|
522
578
|
await this.#reconcileArtifacts(request.worldId);
|
|
523
579
|
return this.#frontendDebug(request.worldId, request.operationId);
|
|
524
|
-
case "world.repair-materialization": {
|
|
525
|
-
const result = await this.#worlds.repairMaterialization(request.worldId);
|
|
526
|
-
await this.#reconcileArtifacts(request.worldId);
|
|
527
|
-
return result;
|
|
528
|
-
}
|
|
529
580
|
case "world.control-draft.save":
|
|
530
581
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
531
582
|
return this.#worlds.saveControlDraft(request.worldId, request.files);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { mkdir,
|
|
3
|
+
import { mkdir, readFile, writeFile, readdir, rename, rm, stat, } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { worldArchiveFormat, worldArchiveLimits, } from "../../protocol/worldArchives.js";
|
|
6
6
|
import { FileNativeArtifactStore } from "../artifact/FileNativeArtifactStore.js";
|
|
7
7
|
import { PlayCallChain } from "../play/PlayCallChain.js";
|
|
8
8
|
import { FileNativePromptCompiler } from "../prompt/FileNativePromptCompiler.js";
|
|
9
|
-
import {
|
|
9
|
+
import { WorldLedgerAuthority } from "../world/WorldLedgerAuthority.js";
|
|
10
|
+
import { forgetLedger } from "../world/WorldTurnLedger.js";
|
|
10
11
|
import { FileNativeWorldStore, } from "../world/FileNativeWorldStore.js";
|
|
11
12
|
import { archivePath, extractArchive, inventory, streamFile, writeArchive, WorldArchiveError, } from "./WorldArchiveZip.js";
|
|
12
13
|
export class WorldArchives {
|
|
@@ -43,8 +44,6 @@ export class WorldArchives {
|
|
|
43
44
|
const path = this.#uploadPath(uploadId);
|
|
44
45
|
try {
|
|
45
46
|
await streamFile(source, path, this.#limits.archiveBytes);
|
|
46
|
-
await syncFile(path);
|
|
47
|
-
crashAt("upload");
|
|
48
47
|
return { uploadId };
|
|
49
48
|
}
|
|
50
49
|
catch (error) {
|
|
@@ -69,7 +68,6 @@ export class WorldArchives {
|
|
|
69
68
|
targetName: source.title,
|
|
70
69
|
publish: false,
|
|
71
70
|
targetWorldRoot: join(root, "world"),
|
|
72
|
-
requireMaterialized: true,
|
|
73
71
|
resetPackageScriptPermissions: true,
|
|
74
72
|
retainCompleteTimeline: true,
|
|
75
73
|
});
|
|
@@ -84,7 +82,7 @@ export class WorldArchives {
|
|
|
84
82
|
exportedAt: new Date().toISOString(),
|
|
85
83
|
worldName: source.title,
|
|
86
84
|
archivedWorldId: fork.world.worldId,
|
|
87
|
-
head: (await new
|
|
85
|
+
head: (await new WorldLedgerAuthority(fork.worldRoot).readHead())
|
|
88
86
|
.head,
|
|
89
87
|
files,
|
|
90
88
|
};
|
|
@@ -152,7 +150,6 @@ export class WorldArchives {
|
|
|
152
150
|
published.worldId !== worldId)
|
|
153
151
|
throw new WorldArchiveError("integrity_failed", "Import publication does not match its operation");
|
|
154
152
|
await this.#worlds.ensureCurrentStorage(worldId);
|
|
155
|
-
await durableJson(join(this.#dataRoot, "operations", `import-${createHash("sha256").update(input.operationId).digest("hex")}.json`), JSON.parse(await readFile(join(targetRoot, "publication.json"), "utf8")));
|
|
156
153
|
const world = await this.#worlds.readWorldSummary(worldId);
|
|
157
154
|
return { outcome: "imported", world };
|
|
158
155
|
}
|
|
@@ -166,7 +163,6 @@ export class WorldArchives {
|
|
|
166
163
|
});
|
|
167
164
|
const extracted = join(quarantine, "extracted");
|
|
168
165
|
await mkdir(extracted, { recursive: true, mode: 0o700 });
|
|
169
|
-
crashAt("quarantine");
|
|
170
166
|
let actual;
|
|
171
167
|
try {
|
|
172
168
|
actual = await extractArchive(upload, extracted, this.#limits);
|
|
@@ -176,7 +172,6 @@ export class WorldArchives {
|
|
|
176
172
|
throw new WorldArchiveError("invalid_archive", "Upload is missing; upload the ZIP again");
|
|
177
173
|
throw error;
|
|
178
174
|
}
|
|
179
|
-
crashAt("unpack");
|
|
180
175
|
if (!actual.has("narraeon-world.json"))
|
|
181
176
|
throw new WorldArchiveError("invalid_archive", "Archive manifest is missing");
|
|
182
177
|
let value;
|
|
@@ -196,6 +191,7 @@ export class WorldArchives {
|
|
|
196
191
|
}
|
|
197
192
|
const sourceRoot = join(quarantine, "worlds-file-native", manifest.archivedWorldId);
|
|
198
193
|
await mkdir(dirname(sourceRoot), { recursive: true });
|
|
194
|
+
forgetLedger(sourceRoot);
|
|
199
195
|
try {
|
|
200
196
|
await rename(join(extracted, "world"), sourceRoot);
|
|
201
197
|
}
|
|
@@ -214,18 +210,16 @@ export class WorldArchives {
|
|
|
214
210
|
if (publication?.worldId !== manifest.archivedWorldId)
|
|
215
211
|
throw new Error("Archive world identity mismatch");
|
|
216
212
|
await sourceWorlds.ensureCurrentStorage(manifest.archivedWorldId);
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
213
|
+
// 结构校验:端点与清单一致,账本能倒推回 genesis;投影在分叉时重建。
|
|
214
|
+
const authority = new WorldLedgerAuthority(sourceRoot);
|
|
215
|
+
const head = await authority.readHead();
|
|
216
|
+
if (head.head !== manifest.head)
|
|
220
217
|
throw new Error("Archive head mismatch");
|
|
221
|
-
await authority.
|
|
222
|
-
for (const commit of history.commits)
|
|
223
|
-
await authority.recover(commit.head);
|
|
218
|
+
await authority.verifyStructure();
|
|
224
219
|
}
|
|
225
220
|
catch (error) {
|
|
226
221
|
throw new WorldArchiveError("integrity_failed", "Archive Authority failed its complete audit", { cause: error });
|
|
227
222
|
}
|
|
228
|
-
crashAt("audit");
|
|
229
223
|
const sourceChains = new PlayCallChain(sourceWorlds, new FileNativePromptCompiler(), new FileNativeArtifactStore(quarantine));
|
|
230
224
|
let result;
|
|
231
225
|
try {
|
|
@@ -244,7 +238,6 @@ export class WorldArchives {
|
|
|
244
238
|
catch (error) {
|
|
245
239
|
throw new WorldArchiveError("integrity_failed", "Archive world closure cannot be restored", { cause: error });
|
|
246
240
|
}
|
|
247
|
-
crashAt("publish");
|
|
248
241
|
return { outcome: "imported", world: result.world };
|
|
249
242
|
}
|
|
250
243
|
finally {
|
|
@@ -335,34 +328,14 @@ function archiveBaseName(name) {
|
|
|
335
328
|
.replaceAll(/^[.-]+|[.-]+$/gu, "")
|
|
336
329
|
.slice(0, 80) || "world");
|
|
337
330
|
}
|
|
338
|
-
async function syncFile(path) {
|
|
339
|
-
const file = await open(path, "r");
|
|
340
|
-
try {
|
|
341
|
-
await file.sync();
|
|
342
|
-
}
|
|
343
|
-
finally {
|
|
344
|
-
await file.close();
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
331
|
async function durableJson(path, value) {
|
|
348
332
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
349
333
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
finally {
|
|
356
|
-
await file.close();
|
|
357
|
-
}
|
|
334
|
+
await writeFile(temporary, JSON.stringify(value), {
|
|
335
|
+
flag: "wx",
|
|
336
|
+
mode: 0o600,
|
|
337
|
+
});
|
|
358
338
|
await rename(temporary, path);
|
|
359
|
-
const directory = await open(dirname(path), "r");
|
|
360
|
-
try {
|
|
361
|
-
await directory.sync();
|
|
362
|
-
}
|
|
363
|
-
finally {
|
|
364
|
-
await directory.close();
|
|
365
|
-
}
|
|
366
339
|
}
|
|
367
340
|
async function appVersion() {
|
|
368
341
|
let directory = import.meta.dirname;
|
|
@@ -382,8 +355,3 @@ async function appVersion() {
|
|
|
382
355
|
directory = parent;
|
|
383
356
|
}
|
|
384
357
|
}
|
|
385
|
-
function crashAt(edge) {
|
|
386
|
-
if (process.env.NODE_ENV === "test" &&
|
|
387
|
-
process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_WORLD_ARCHIVE_EDGE === edge)
|
|
388
|
-
process.exit(89);
|
|
389
|
-
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { isWorldExtensionControl } from "../../protocol/worldExtensions.js";
|
|
2
|
+
import { isFrozenArtifactPresentation } from "../play/FileNativePlayPresetStore.js";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
export class ArtifactStoreInvariantError extends Error {
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = "ArtifactStoreInvariantError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ArtifactStoreCorruptionError extends ArtifactStoreInvariantError {
|
|
13
|
+
constructor(message, options) {
|
|
14
|
+
super(message, options);
|
|
15
|
+
this.name = "ArtifactStoreCorruptionError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function artifactSequenceFileName(sequence) {
|
|
19
|
+
return `${String(sequence).padStart(12, "0")}.json`;
|
|
20
|
+
}
|
|
21
|
+
export function identityHash(value) {
|
|
22
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
23
|
+
}
|
|
24
|
+
export function rawRecordFingerprint(record) {
|
|
25
|
+
const content = { ...record };
|
|
26
|
+
delete content.recordFingerprint;
|
|
27
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(content)).digest("hex")}`;
|
|
28
|
+
}
|
|
29
|
+
export function payloadFingerprint(output, payload) {
|
|
30
|
+
return `sha256:${createHash("sha256").update(JSON.stringify({ output, payload })).digest("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
export function stableSerialize(value) {
|
|
33
|
+
if (value === null || typeof value !== "object")
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
|
|
37
|
+
return `{${Object.keys(value)
|
|
38
|
+
.sort()
|
|
39
|
+
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
|
|
40
|
+
.join(",")}}`;
|
|
41
|
+
}
|
|
42
|
+
/** Heads are linear: genesis is round 0 and commit:N is round N. */
|
|
43
|
+
export function headOrdinal(head) {
|
|
44
|
+
if (head === "genesis")
|
|
45
|
+
return 0;
|
|
46
|
+
const match = /^commit:([1-9][0-9]*)$/u.exec(head);
|
|
47
|
+
if (match === null)
|
|
48
|
+
throw new ArtifactStoreInvariantError("Artifact head is invalid");
|
|
49
|
+
return Number(match[1]);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Writes a JSON file by temporary file and rename. The temporary name is
|
|
53
|
+
* unique, so an interrupted write never leaves a partial file under `path`.
|
|
54
|
+
*/
|
|
55
|
+
export async function writeArtifactJson(path, value) {
|
|
56
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
57
|
+
const target = `${path}.${randomUUID()}.tmp`;
|
|
58
|
+
let handle;
|
|
59
|
+
let created = false;
|
|
60
|
+
try {
|
|
61
|
+
handle = await open(target, "wx", 0o600);
|
|
62
|
+
created = true;
|
|
63
|
+
await handle.writeFile(JSON.stringify(value), "utf8");
|
|
64
|
+
await handle.close();
|
|
65
|
+
handle = undefined;
|
|
66
|
+
await rename(target, path);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await handle?.close().catch(() => undefined);
|
|
70
|
+
if (created)
|
|
71
|
+
await rm(target, { force: true }).catch(() => undefined);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function parseArtifactJson(bytes, label) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(bytes);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new ArtifactStoreCorruptionError(`${label} JSON is corrupt`, error instanceof Error ? { cause: error } : undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function isRecord(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
export function hasExactKeys(value, required, optional = []) {
|
|
87
|
+
const allowed = new Set([...required, ...optional]);
|
|
88
|
+
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
89
|
+
Object.keys(value).every((key) => allowed.has(key)));
|
|
90
|
+
}
|
|
91
|
+
export function isNonEmptyString(value) {
|
|
92
|
+
return typeof value === "string" && value.trim() !== "";
|
|
93
|
+
}
|
|
94
|
+
export function isSha256(value) {
|
|
95
|
+
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/u.test(value);
|
|
96
|
+
}
|
|
97
|
+
export function isMissing(error) {
|
|
98
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
99
|
+
}
|
|
100
|
+
export function isJsonValue(value) {
|
|
101
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
102
|
+
return true;
|
|
103
|
+
if (typeof value === "number")
|
|
104
|
+
return Number.isFinite(value);
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return value.every(isJsonValue);
|
|
107
|
+
if (typeof value !== "object")
|
|
108
|
+
return false;
|
|
109
|
+
return Object.values(value).every(isJsonValue);
|
|
110
|
+
}
|
|
111
|
+
export function validAttachment(value) {
|
|
112
|
+
return (isRecord(value) &&
|
|
113
|
+
hasExactKeys(value, ["contextId", "eventId", "runId", "head"]) &&
|
|
114
|
+
isNonEmptyString(value.head) &&
|
|
115
|
+
isNonEmptyString(value.contextId) &&
|
|
116
|
+
isNonEmptyString(value.runId) &&
|
|
117
|
+
typeof value.eventId === "number" &&
|
|
118
|
+
Number.isSafeInteger(value.eventId) &&
|
|
119
|
+
value.eventId > 0);
|
|
120
|
+
}
|
|
121
|
+
export function assertRawRecord(record) {
|
|
122
|
+
if (!isRecord(record) ||
|
|
123
|
+
!hasExactKeys(record, [
|
|
124
|
+
"schemaVersion",
|
|
125
|
+
"recordId",
|
|
126
|
+
"sequence",
|
|
127
|
+
"worldId",
|
|
128
|
+
"parentHead",
|
|
129
|
+
"head",
|
|
130
|
+
"operationId",
|
|
131
|
+
"playPresetId",
|
|
132
|
+
"playPresetRevision",
|
|
133
|
+
"playPresetScriptsEnabled",
|
|
134
|
+
"requestId",
|
|
135
|
+
"requestAttempt",
|
|
136
|
+
"output",
|
|
137
|
+
"channel",
|
|
138
|
+
"contentType",
|
|
139
|
+
"payload",
|
|
140
|
+
"save",
|
|
141
|
+
"projection",
|
|
142
|
+
"invalidation",
|
|
143
|
+
"toolCallId",
|
|
144
|
+
"toolName",
|
|
145
|
+
"callFingerprint",
|
|
146
|
+
"payloadFingerprint",
|
|
147
|
+
"recordFingerprint",
|
|
148
|
+
"status",
|
|
149
|
+
], [
|
|
150
|
+
"key",
|
|
151
|
+
"renderer",
|
|
152
|
+
"rendererRevision",
|
|
153
|
+
"frozenPresentation",
|
|
154
|
+
"extensionControl",
|
|
155
|
+
"clearedBy",
|
|
156
|
+
]) ||
|
|
157
|
+
(record.clearedBy !== undefined &&
|
|
158
|
+
!isClearedBy(record.clearedBy, record.status)) ||
|
|
159
|
+
(record.extensionControl !== undefined &&
|
|
160
|
+
!isWorldExtensionControl(record.extensionControl)) ||
|
|
161
|
+
(record.frozenPresentation !== undefined &&
|
|
162
|
+
!isFrozenArtifactPresentation(record.frozenPresentation)) ||
|
|
163
|
+
record.schemaVersion !== 1 ||
|
|
164
|
+
!isNonEmptyString(record.recordId) ||
|
|
165
|
+
!Number.isSafeInteger(record.sequence) ||
|
|
166
|
+
record.sequence < 1 ||
|
|
167
|
+
!isNonEmptyString(record.worldId) ||
|
|
168
|
+
!isNonEmptyString(record.operationId) ||
|
|
169
|
+
!isNonEmptyString(record.output) ||
|
|
170
|
+
!isNonEmptyString(record.channel) ||
|
|
171
|
+
!isNonEmptyString(record.parentHead) ||
|
|
172
|
+
!isNonEmptyString(record.playPresetId) ||
|
|
173
|
+
!isNonEmptyString(record.playPresetRevision) ||
|
|
174
|
+
!isNonEmptyString(record.requestId) ||
|
|
175
|
+
!Number.isSafeInteger(record.requestAttempt) ||
|
|
176
|
+
record.requestAttempt < 1 ||
|
|
177
|
+
(record.head !== null && !isNonEmptyString(record.head)) ||
|
|
178
|
+
typeof record.playPresetScriptsEnabled !== "boolean" ||
|
|
179
|
+
!["text/plain", "text/markdown", "application/json", "text/html"].includes(record.contentType) ||
|
|
180
|
+
!["none", "operation", "commit"].includes(record.save) ||
|
|
181
|
+
!["append", "replace", "upsert", "transient", "hidden"].includes(record.projection) ||
|
|
182
|
+
![
|
|
183
|
+
"new_operation",
|
|
184
|
+
"head_change",
|
|
185
|
+
"operation_end",
|
|
186
|
+
"explicit_clear",
|
|
187
|
+
"never",
|
|
188
|
+
].includes(record.invalidation) ||
|
|
189
|
+
!["pending", "active", "superseded", "cleared"].includes(record.status) ||
|
|
190
|
+
typeof record.toolCallId !== "string" ||
|
|
191
|
+
record.toolName !== "artifact_emit" ||
|
|
192
|
+
!isSha256(record.callFingerprint) ||
|
|
193
|
+
!isSha256(record.payloadFingerprint) ||
|
|
194
|
+
!isSha256(record.recordFingerprint) ||
|
|
195
|
+
(record.renderer === undefined) !==
|
|
196
|
+
(record.rendererRevision === undefined) ||
|
|
197
|
+
(record.renderer !== undefined && !isNonEmptyString(record.renderer)) ||
|
|
198
|
+
(record.rendererRevision !== undefined &&
|
|
199
|
+
!isNonEmptyString(record.rendererRevision)) ||
|
|
200
|
+
(record.projection === "upsert"
|
|
201
|
+
? !isNonEmptyString(record.key)
|
|
202
|
+
: record.key !== undefined))
|
|
203
|
+
throw new ArtifactStoreCorruptionError("Artifact raw record is corrupt");
|
|
204
|
+
if (!isJsonValue(record.payload))
|
|
205
|
+
throw new ArtifactStoreCorruptionError("Artifact payload is not a JSON value");
|
|
206
|
+
if (record.payloadFingerprint !==
|
|
207
|
+
payloadFingerprint(record.output, record.payload))
|
|
208
|
+
throw new ArtifactStoreCorruptionError("Artifact payload fingerprint does not match");
|
|
209
|
+
if (record.recordFingerprint !== rawRecordFingerprint(record))
|
|
210
|
+
throw new ArtifactStoreCorruptionError("Artifact raw-record fingerprint does not match");
|
|
211
|
+
}
|
|
212
|
+
function isClearedBy(value, status) {
|
|
213
|
+
return (isRecord(value) &&
|
|
214
|
+
hasExactKeys(value, [
|
|
215
|
+
"operationId",
|
|
216
|
+
"requestId",
|
|
217
|
+
"requestAttempt",
|
|
218
|
+
"priorStatus",
|
|
219
|
+
]) &&
|
|
220
|
+
status === "cleared" &&
|
|
221
|
+
isNonEmptyString(value.operationId) &&
|
|
222
|
+
isNonEmptyString(value.requestId) &&
|
|
223
|
+
Number.isSafeInteger(value.requestAttempt) &&
|
|
224
|
+
value.requestAttempt >= 1 &&
|
|
225
|
+
(value.priorStatus === "pending" || value.priorStatus === "active"));
|
|
226
|
+
}
|
|
227
|
+
export function assertOperation(operation) {
|
|
228
|
+
const context = operation.context;
|
|
229
|
+
if (!isRecord(operation) ||
|
|
230
|
+
!hasExactKeys(operation, [
|
|
231
|
+
"schemaVersion",
|
|
232
|
+
"context",
|
|
233
|
+
"status",
|
|
234
|
+
"extensionStatus",
|
|
235
|
+
"completedRequests",
|
|
236
|
+
"artifactProjectionStatus",
|
|
237
|
+
"requestAttempts",
|
|
238
|
+
], ["message", "head", "requests"]) ||
|
|
239
|
+
(operation.requests !== undefined &&
|
|
240
|
+
(!Array.isArray(operation.requests) ||
|
|
241
|
+
!operation.requests.every((item) => isRecord(item) &&
|
|
242
|
+
hasExactKeys(item, [
|
|
243
|
+
"requestId",
|
|
244
|
+
"displayName",
|
|
245
|
+
"mounts",
|
|
246
|
+
"status",
|
|
247
|
+
]) &&
|
|
248
|
+
isNonEmptyString(item.requestId) &&
|
|
249
|
+
isNonEmptyString(item.displayName) &&
|
|
250
|
+
Array.isArray(item.mounts) &&
|
|
251
|
+
item.mounts.every((mount) => typeof mount === "string" &&
|
|
252
|
+
[
|
|
253
|
+
"story",
|
|
254
|
+
"sidebar",
|
|
255
|
+
"composer_above",
|
|
256
|
+
"composer_below",
|
|
257
|
+
"overlay",
|
|
258
|
+
"debug",
|
|
259
|
+
].includes(mount)) &&
|
|
260
|
+
["running", "completed", "failed"].includes(item.status)))) ||
|
|
261
|
+
operation.schemaVersion !== 1 ||
|
|
262
|
+
!isRecord(context) ||
|
|
263
|
+
!hasExactKeys(context, [
|
|
264
|
+
"worldId",
|
|
265
|
+
"parentHead",
|
|
266
|
+
"operationId",
|
|
267
|
+
"playPresetId",
|
|
268
|
+
"playPresetRevision",
|
|
269
|
+
"playPresetScriptsEnabled",
|
|
270
|
+
], ["attachment"]) ||
|
|
271
|
+
(context.attachment !== undefined &&
|
|
272
|
+
!validAttachment(context.attachment)) ||
|
|
273
|
+
!isNonEmptyString(context.worldId) ||
|
|
274
|
+
!isNonEmptyString(context.parentHead) ||
|
|
275
|
+
!isNonEmptyString(context.operationId) ||
|
|
276
|
+
!isNonEmptyString(context.playPresetId) ||
|
|
277
|
+
!isNonEmptyString(context.playPresetRevision) ||
|
|
278
|
+
typeof context.playPresetScriptsEnabled !== "boolean" ||
|
|
279
|
+
!["running", "core_failed", "core_committed", "completed"].includes(operation.status) ||
|
|
280
|
+
![
|
|
281
|
+
"not_started",
|
|
282
|
+
"running",
|
|
283
|
+
"completed",
|
|
284
|
+
"failed",
|
|
285
|
+
"cancelled",
|
|
286
|
+
"budget_exceeded",
|
|
287
|
+
"unknown",
|
|
288
|
+
"recovery_required",
|
|
289
|
+
"superseded",
|
|
290
|
+
].includes(operation.extensionStatus) ||
|
|
291
|
+
!Array.isArray(operation.completedRequests) ||
|
|
292
|
+
!operation.completedRequests.every(isNonEmptyString) ||
|
|
293
|
+
(operation.message !== undefined &&
|
|
294
|
+
typeof operation.message !== "string") ||
|
|
295
|
+
!validArtifactOperationState(operation) ||
|
|
296
|
+
!["active", "superseded"].includes(operation.artifactProjectionStatus) ||
|
|
297
|
+
!isRecord(operation.requestAttempts) ||
|
|
298
|
+
!Object.entries(operation.requestAttempts).every(([request, attempt]) => request.trim() !== "" &&
|
|
299
|
+
typeof attempt === "number" &&
|
|
300
|
+
Number.isSafeInteger(attempt) &&
|
|
301
|
+
attempt >= 1))
|
|
302
|
+
throw new ArtifactStoreCorruptionError("Artifact operation state is corrupt");
|
|
303
|
+
}
|
|
304
|
+
function validArtifactOperationState(operation) {
|
|
305
|
+
if (operation.status === "running")
|
|
306
|
+
return (operation.extensionStatus === "not_started" &&
|
|
307
|
+
operation.completedRequests.length === 0 &&
|
|
308
|
+
operation.message === undefined &&
|
|
309
|
+
operation.head === undefined);
|
|
310
|
+
if (operation.status === "core_failed")
|
|
311
|
+
return (operation.extensionStatus === "failed" &&
|
|
312
|
+
operation.completedRequests.length === 0 &&
|
|
313
|
+
operation.artifactProjectionStatus === "superseded" &&
|
|
314
|
+
operation.head === undefined);
|
|
315
|
+
if (operation.status === "completed")
|
|
316
|
+
return (operation.extensionStatus === "completed" &&
|
|
317
|
+
isNonEmptyString(operation.head) &&
|
|
318
|
+
operation.message === undefined);
|
|
319
|
+
if (!isNonEmptyString(operation.head))
|
|
320
|
+
return false;
|
|
321
|
+
if (operation.extensionStatus === "completed")
|
|
322
|
+
return false;
|
|
323
|
+
if (operation.extensionStatus === "not_started" ||
|
|
324
|
+
operation.extensionStatus === "running")
|
|
325
|
+
return (operation.completedRequests.length === 0 &&
|
|
326
|
+
operation.message === undefined);
|
|
327
|
+
return (isNonEmptyString(operation.message) &&
|
|
328
|
+
(operation.extensionStatus !== "superseded" ||
|
|
329
|
+
operation.artifactProjectionStatus === "superseded"));
|
|
330
|
+
}
|