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
package/README.md CHANGED
@@ -144,6 +144,13 @@ English is the default interface language. Switching to Simplified Chinese updat
144
144
 
145
145
  Existing content packages, worlds, imported presets, copied presets, and user-edited prompt files are not translated or rewritten automatically.
146
146
 
147
+ ## Android
148
+
149
+ An experimental Android app runs the same backend inside the app process: a
150
+ foreground service starts the embedded Node runtime, and a WebView opens
151
+ `127.0.0.1:4317`. It supports `arm64-v8a` devices only. Build steps and the
152
+ runtime layout are in [android/README.md](android/README.md).
153
+
147
154
  ## Local data and privacy
148
155
 
149
156
  Narraeon uses the operating system's standard per-user application directories. To isolate an instance or choose explicit locations, set these variables before startup:
@@ -165,6 +172,10 @@ When a provider, response-format, Runtime-tool, or candidate-check error occurs,
165
172
 
166
173
  Every Runtime request that fails, every play call that ends interrupted, and every failed live conversation sync is also appended to `NARRAEON_LOG_ROOT/runtime-errors/<UTC date>.jsonl` with the request type, its identifiers (world, context, event, and operation IDs), and the error message, code, stack, and cause chain. Player and world text from the request is not recorded. Include the matching line when reporting a bug.
167
174
 
175
+ ## Upgrading worlds
176
+
177
+ Worlds written by v0.3.0 through v0.6.0 upgrade automatically the first time a current version opens them. Worlds written by v0.2.0 or earlier are not upgraded directly: open each one once with v0.3.0–v0.6.0, then open it with the current version. A world that cannot be upgraded is shown as unavailable and is not modified.
178
+
168
179
  ## Development
169
180
 
170
181
  ```bash
package/README.zh-CN.md CHANGED
@@ -136,6 +136,10 @@ AI 设定完善只作用于内容包候选。在“先生成计划”的路径
136
136
 
137
137
  已有内容包、世界、导入预设、复制预设和用户编辑过的提示文件不会被自动翻译或重写。
138
138
 
139
+ ## Android
140
+
141
+ 实验性的 Android 应用在 App 进程内运行同一套后端:前台服务启动内嵌的 Node 运行时,WebView 打开 `127.0.0.1:4317`。目前只支持 `arm64-v8a` 设备。构建步骤与运行时目录结构见 [android/README.md](android/README.md)。
142
+
139
143
  ## 本地数据与隐私
140
144
 
141
145
  Narraeon 默认使用当前操作系统的标准用户应用目录。如果要隔离一份独立实例,或者自行指定存放位置,可在启动前设置:
@@ -155,6 +159,10 @@ Narraeon 默认使用当前操作系统的标准用户应用目录。如果要
155
159
 
156
160
  每个失败的 Runtime 请求、每次以中断结束的游玩调用、每次失败的对话实时同步,也会追加到 `NARRAEON_LOG_ROOT/runtime-errors/<UTC 日期>.jsonl`,记录请求类型、请求中的标识(世界、上下文、事件和操作 ID)以及错误信息、错误码、调用栈和 cause 链,不记录请求里的玩家原文和世界内容。反馈问题时附上对应的那一行即可。
157
161
 
162
+ ## 升级世界
163
+
164
+ 由 v0.3.0 至 v0.6.0 创建的世界会在当前版本第一次打开时自动升级。由 v0.2.0 及更早版本创建的世界不再直接升级:先用 v0.3.0–v0.6.0 各打开一次,再用当前版本打开。无法升级的世界会显示为不可打开,并且不会被修改。
165
+
158
166
  ## 仓库开发
159
167
 
160
168
  ```bash
@@ -68,6 +68,11 @@ const requiredFields = {
68
68
  continuation: "object",
69
69
  },
70
70
  "setting-improvement.cancel": { sessionId: "string" },
71
+ "setting-improvement.resume": {
72
+ packageId: "string",
73
+ sessionId: "string",
74
+ requestId: "string",
75
+ },
71
76
  "play.create": { name: "string" },
72
77
  "play.copy": { presetId: "string" },
73
78
  "play.save": { presetId: "string", name: "string", files: "object" },
@@ -98,7 +103,6 @@ const requiredFields = {
98
103
  "artifacts.read": { worldId: "string" },
99
104
  "artifacts.debug": { worldId: "string" },
100
105
  "world.creation-outcome": { operationId: "string" },
101
- "world.repair-materialization": { worldId: "string" },
102
106
  "world.control-draft.save": { worldId: "string", files: "array" },
103
107
  "world.control-draft.preview": { worldId: "string" },
104
108
  "world.control-draft.apply": { worldId: "string" },
@@ -121,6 +125,11 @@ const requiredFields = {
121
125
  continuation: "object",
122
126
  },
123
127
  "world.revision.cancel": { sessionId: "string" },
128
+ "world.revision.resume": {
129
+ worldId: "string",
130
+ sessionId: "string",
131
+ requestId: "string",
132
+ },
124
133
  "world.revision.files.replace": {
125
134
  worldId: "string",
126
135
  epochId: "string",
@@ -423,6 +432,7 @@ const requestTypes = new Set([
423
432
  "setting-improvement.rollback",
424
433
  "setting-improvement.message",
425
434
  "setting-improvement.cancel",
435
+ "setting-improvement.resume",
426
436
  "play.read",
427
437
  "play.create",
428
438
  "play.copy",
@@ -446,7 +456,6 @@ const requestTypes = new Set([
446
456
  "artifacts.read",
447
457
  "artifacts.debug",
448
458
  "world.creation-outcome",
449
- "world.repair-materialization",
450
459
  "world.control-draft.save",
451
460
  "world.control-draft.preview",
452
461
  "world.control-draft.apply",
@@ -458,6 +467,7 @@ const requestTypes = new Set([
458
467
  "world.revision.session.delete",
459
468
  "world.revision.message",
460
469
  "world.revision.cancel",
470
+ "world.revision.resume",
461
471
  "world.revision.files.replace",
462
472
  "world.revision.rollback",
463
473
  "world.revision.apply",
@@ -0,0 +1,39 @@
1
+ import { constants } from "node:fs";
2
+ import { copyFile, link } from "node:fs/promises";
3
+ // Error codes that mean "hard links are unavailable here". Android app directories
4
+ // return EACCES for link(), so that code is included.
5
+ const linkFallbackCodes = new Set(["EPERM", "EACCES", "ENOTSUP", "EXDEV"]);
6
+ /**
7
+ * Creates target as a new name for source, failing with EEXIST when target already
8
+ * exists. Uses a hard link where the filesystem allows it. Otherwise falls back to an
9
+ * exclusive byte copy, which also fails with EEXIST.
10
+ *
11
+ * Creation is exclusive in both paths. Content visibility is atomic only on the
12
+ * hard-link path: on the copy path a concurrent reader could in principle see a
13
+ * partially written target. This is acceptable under ADR-0044's single-process and
14
+ * per-world serial assumptions, because callers hold a process-local lock and
15
+ * readers only open targets after the creating call returns.
16
+ */
17
+ export async function linkExclusive(source, target) {
18
+ const injectedErrorCode = process.env.NARRAEON_INTERNAL_TEST_LINK_ERROR_CODE;
19
+ try {
20
+ if (injectedErrorCode !== undefined) {
21
+ const error = new Error(`${injectedErrorCode}: injected hard link failure`);
22
+ error.code = injectedErrorCode;
23
+ throw error;
24
+ }
25
+ await link(source, target);
26
+ }
27
+ catch (error) {
28
+ if (!isFallbackError(error))
29
+ throw error;
30
+ await copyFile(source, target, constants.COPYFILE_EXCL);
31
+ }
32
+ }
33
+ function isFallbackError(error) {
34
+ return (typeof error === "object" &&
35
+ error !== null &&
36
+ "code" in error &&
37
+ typeof error.code === "string" &&
38
+ linkFallbackCodes.has(error.code));
39
+ }
@@ -383,6 +383,8 @@ export class V1Runtime {
383
383
  return this.#settingImprovements.send(request);
384
384
  case "setting-improvement.cancel":
385
385
  return this.#settingImprovements.cancel(request.sessionId);
386
+ case "setting-improvement.resume":
387
+ return this.#settingImprovements.resume(request);
386
388
  case "play.read":
387
389
  return this.#playPresets.list();
388
390
  case "play.create":
@@ -496,6 +498,7 @@ export class V1Runtime {
496
498
  case "world.delete":
497
499
  return playCall(async () => {
498
500
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
501
+ await this.#playCallChains.abortWorld(request.worldId);
499
502
  this.#playCallChains.forgetWorld(request.worldId);
500
503
  return this.#worlds.deleteWorld(request.worldId);
501
504
  });
@@ -576,11 +579,6 @@ export class V1Runtime {
576
579
  case "artifacts.debug":
577
580
  await this.#reconcileArtifacts(request.worldId);
578
581
  return this.#frontendDebug(request.worldId, request.operationId);
579
- case "world.repair-materialization": {
580
- const result = await this.#worlds.repairMaterialization(request.worldId);
581
- await this.#reconcileArtifacts(request.worldId);
582
- return result;
583
- }
584
582
  case "world.control-draft.save":
585
583
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
586
584
  return this.#worlds.saveControlDraft(request.worldId, request.files);
@@ -618,6 +616,8 @@ export class V1Runtime {
618
616
  return this.#worldRevisionCall(() => this.#worldRevisions.send(request));
619
617
  case "world.revision.cancel":
620
618
  return this.#worldRevisionCall(() => this.#worldRevisions.cancel(request.sessionId));
619
+ case "world.revision.resume":
620
+ return this.#worldRevisionCall(() => this.#worldRevisions.resume(request));
621
621
  case "world.revision.files.replace":
622
622
  return this.#worldRevisionCall(() => this.#worldRevisions.replaceFiles(request));
623
623
  case "world.revision.rollback":
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
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 { FileNativeAuthorityV3 } from "../world/FileNativeAuthorityV3.js";
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 FileNativeAuthorityV3(fork.worldRoot).readHead())
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
- const authority = new FileNativeAuthorityV3(sourceRoot);
218
- const history = await authority.readHistory();
219
- if (history.head !== manifest.head)
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.recover("genesis");
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
- const file = await open(temporary, "wx", 0o600);
351
- try {
352
- await file.writeFile(JSON.stringify(value));
353
- await file.sync();
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
+ }