narraeon 0.5.6 → 0.5.7
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/v1.js +25 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/V1Runtime.js +46 -6
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +5 -0
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +80 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/PlayCallChain.js +144 -52
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +4 -22
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
- package/dist/node/runtime/world/FileNativeWorldStore.js +172 -39
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-play-prompts.js +2 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/web/assets/index-DxNtxzLR.js +1255 -0
- package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
|
@@ -239,7 +239,7 @@ function createToolDefinitions(locale) {
|
|
|
239
239
|
}
|
|
240
240
|
const toolDescriptions = {
|
|
241
241
|
en: {
|
|
242
|
-
world_checkpoint: "Register a
|
|
242
|
+
world_checkpoint: "Register a gap between events: the previous event has naturally settled at this turn's stopping point, or the scene has actually changed, suggesting that the player start a fresh context here. Arguments must be an empty object. Registration takes effect only after the subsequent nonempty tool-free final prose successfully commits, and includes that prose. A fresh context replays only committed player inputs and final narratives after this boundary, so save still-needed outcomes before registration. Consolidation and maintenance do not require this tool. Do not advance the story to register a checkpoint; registration neither switches context nor ends an in-world event. Further tool calls may follow registration; Runtime does not verify semantic completeness.",
|
|
243
243
|
state_list: "List document and state directory handles known to Runtime. parent must be @dir-/ for the state root or an @dir-* handle from the initial directory index or an earlier state_list result; no preliminary listing is needed for an already supplied handle. Catalog directories declared by the frozen world frame remain listed and usable when empty. A cursor is valid only for the same state snapshot, parent, and limit. Pass returned document handles to context_read. This tool never lists committed history.",
|
|
244
244
|
history_list: "Browse committed history on the current timeline. Omit anchors for the latest page; use only before or after with a Runtime ref (including refs in injected history) for the immediately adjacent page, excluding the anchor. Every page is oldest to newest. limit defaults to 20 (1–100 messages). Previews are only the first 120 Unicode code points. Use independent earlierCursor/laterCursor with {cursor} alone; no first-query parameters on continuation. Cursors bind world, tool, query and immutable ordered history. After history_changed rerun the query. Opening messages are queryable. Read full originals with history_read; positions are snapshot locations, not identities or turns.",
|
|
245
245
|
history_search: "Search committed originals on the current timeline with queries: 1–5 nonempty literal strings (max 256 code points each); no query alias or semantic filters. match defaults to any, or all within one message. NFKC normalization always applies; caseSensitive defaults to false (locale-und case folding), normalized duplicate queries collapse. order defaults to newest_first, or oldest_first. limit defaults to 10 (1–50 matching messages). Optional after/before refs exclude timeline boundaries independently of order; after must precede before. Each result has at most 3 partial original fragments, query occurrence counts and complete-original length. Read a hit and nearby context with history_read. Continue with {cursor} alone; history_changed requires a new query.",
|
|
@@ -253,7 +253,7 @@ const toolDescriptions = {
|
|
|
253
253
|
artifact_clear: "Clear the projection of an artifact that this follow-up declared in advance and explicitly allows to be cleared. The call cannot choose a channel, key, or any other output contract.",
|
|
254
254
|
},
|
|
255
255
|
"zh-CN": {
|
|
256
|
-
world_checkpoint: "
|
|
256
|
+
world_checkpoint: "登记事件间隙:上一事件已在本轮停笔位置自然告一段落,或场景已实际转换,建议玩家从这里开始全新上下文。参数为空对象。仅在随后非空且无工具调用的最终正文成功提交后生效,边界包含该正文;全新上下文只补入边界之后的已提交玩家原文与最终叙事,因此登记前应已保存后续仍需要的结果。整理与归并不需要调用本工具。不要为了登记而推进剧情;登记不切换上下文,也不结束世界内事件。登记后仍可继续工具调用;Runtime 不认证语义完整。",
|
|
257
257
|
state_list: "列出 Runtime 已知的文档与状态目录句柄。状态根目录的 parent 使用 @dir-/;继续下级时使用初始目录索引或 state_list 返回的 @dir-*;已有句柄无需再次列目录确认。冻结世界 frame 声明的 catalog 目录即使为空,也会继续列出并可用。cursor 只对同一状态快照、parent 和 limit 有效。返回的文档句柄交给 context_read;本工具不列出已提交历史。",
|
|
258
258
|
history_list: "浏览当前时间线已提交历史。无锚点时取最新一页;before 或 after 只能选一个,取锚点紧邻前/后的一页且不含锚点。页内始终由旧到新。limit 默认20,整数1–100。预览仅含前120个Unicode码点;完整原文用history_read。earlierCursor/laterCursor独立翻页,续页只传{cursor},不可混用首次参数。游标绑定世界、工具、查询和有序历史快照;history_changed须重新查询。开场白可查询。提示词注入中的ref可直接使用,无需再次列举;位置不是身份或回合数。",
|
|
259
259
|
history_search: "搜索当前时间线已提交原文。queries必填:1–5个非空字面词,每词最多256个Unicode码点,不接受query别名或语义筛选。match默认any,all限于同一消息。始终NFKC规范化,caseSensitive默认false(locale-und大小写折叠),规范化重复词合并。order默认newest_first,可选oldest_first;limit默认10,整数1–50条命中消息。after/before始终按时间线先后排除边界,after必须早于before,与结果排序无关。每项最多3个部分原文片段、逐词命中次数及完整原文长度;可用history_read读取前后文。续页只传{cursor};history_changed须重新查询。",
|
|
@@ -2,6 +2,8 @@ import { captureNarrativeOrigin, snapshotBackground, isHistoryBackground, } from
|
|
|
2
2
|
import { FileNativeArtifactStore } from "../artifact/FileNativeArtifactStore.js";
|
|
3
3
|
import { WorldExtensionRequests } from "../extension/WorldExtensionRequests.js";
|
|
4
4
|
import { WorldExtensionControls, extensionDefinitions, } from "../extension/WorldExtensionControls.js";
|
|
5
|
+
import { HistoryQuery } from "../history/HistoryQuery.js";
|
|
6
|
+
import { exportStory } from "../history/StoryExport.js";
|
|
5
7
|
import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
|
|
6
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
9
|
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
@@ -11,6 +13,7 @@ import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, }
|
|
|
11
13
|
import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
|
|
12
14
|
import { PlayerViewRenderer } from "./PlayerViewRenderer.js";
|
|
13
15
|
import { WorldDocumentStore } from "./WorldDocumentStore.js";
|
|
16
|
+
import { readWorldActivity, updateWorldActivity, } from "./WorldActivity.js";
|
|
14
17
|
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
15
18
|
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
16
19
|
import { FileNativeWorldStorageMigrator } from "./FileNativeWorldStorageMigrator.js";
|
|
@@ -41,6 +44,7 @@ export class FileNativeWorldStore {
|
|
|
41
44
|
#promptCompiler;
|
|
42
45
|
#activeControlUsers = new Map();
|
|
43
46
|
#openingWorldRevisions = new Set();
|
|
47
|
+
#archiveExports = new Set();
|
|
44
48
|
operations;
|
|
45
49
|
playTimeline;
|
|
46
50
|
playAdvances;
|
|
@@ -112,16 +116,40 @@ export class FileNativeWorldStore {
|
|
|
112
116
|
throw new FileNativeWorldCreationError("world_corrupt", "Released world storage could not be migrated to the current format", error instanceof Error ? { cause: error } : undefined);
|
|
113
117
|
}
|
|
114
118
|
}
|
|
119
|
+
async readWorldSummary(worldId) {
|
|
120
|
+
assertIdentity(worldId, "World ID");
|
|
121
|
+
return readWorldSummaryAt(join(this.#worldsRoot, worldId));
|
|
122
|
+
}
|
|
115
123
|
async listWorlds() {
|
|
116
124
|
await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
|
|
117
|
-
const entries = await readdir(this.#worldsRoot, { withFileTypes: true });
|
|
125
|
+
const entries = (await readdir(this.#worldsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
|
|
118
126
|
const worlds = [];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
// Bound concurrent shell/head/cache reads even in a large workspace.
|
|
128
|
+
for (let offset = 0; offset < entries.length; offset += 16) {
|
|
129
|
+
worlds.push(...(await Promise.all(entries
|
|
130
|
+
.slice(offset, offset + 16)
|
|
131
|
+
.map((entry) => readWorldSummaryAt(join(this.#worldsRoot, entry.name), undefined, (rebuild) => this.operations.withWorldAuthorityLock(entry.name, rebuild))))));
|
|
123
132
|
}
|
|
124
|
-
return worlds
|
|
133
|
+
return worlds.sort((left, right) => (right.lastActivityAt ?? -1) - (left.lastActivityAt ?? -1) ||
|
|
134
|
+
left.title.localeCompare(right.title) ||
|
|
135
|
+
left.worldId.localeCompare(right.worldId));
|
|
136
|
+
}
|
|
137
|
+
/** Explicit full-history read; independent of page and model projections. */
|
|
138
|
+
async exportStory(worldId, format, locale) {
|
|
139
|
+
assertIdentity(worldId, "World ID");
|
|
140
|
+
await this.ensureCurrentStorage(worldId);
|
|
141
|
+
return this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
142
|
+
const root = join(this.#worldsRoot, worldId);
|
|
143
|
+
const publication = await readPublicationAt(root);
|
|
144
|
+
const title = await readWorldTitleAt(root, publication);
|
|
145
|
+
const endpoint = await new FileNativeAuthorityV3(root).recoverHeadResult();
|
|
146
|
+
const history = new HistoryQuery(worldId, endpoint.history.map(({ messageId, role, exactText }) => ({
|
|
147
|
+
id: messageId,
|
|
148
|
+
role,
|
|
149
|
+
text: exactText,
|
|
150
|
+
})));
|
|
151
|
+
return exportStory(title, history.messages, format, locale);
|
|
152
|
+
});
|
|
125
153
|
}
|
|
126
154
|
async renameWorld(worldId, name) {
|
|
127
155
|
assertIdentity(worldId, "World ID");
|
|
@@ -144,7 +172,7 @@ export class FileNativeWorldStore {
|
|
|
144
172
|
worldId,
|
|
145
173
|
name: trimmed,
|
|
146
174
|
});
|
|
147
|
-
return
|
|
175
|
+
return readWorldSummaryAt(root, publication);
|
|
148
176
|
});
|
|
149
177
|
}
|
|
150
178
|
/**
|
|
@@ -321,13 +349,18 @@ export class FileNativeWorldStore {
|
|
|
321
349
|
sourceFingerprint,
|
|
322
350
|
};
|
|
323
351
|
await writeJson(join(stagingRoot, publicationFile), publication);
|
|
352
|
+
await updateWorldActivity(stagingRoot, "genesis", [openingMessage], Date.now());
|
|
324
353
|
await durableTree(stagingRoot);
|
|
325
354
|
crashAtFileNativeWorldCreationEdge("after_staging");
|
|
326
355
|
await rename(stagingRoot, finalRoot);
|
|
327
356
|
await syncDirectory(this.#worldsRoot);
|
|
328
357
|
crashAtFileNativeWorldCreationEdge("after_atomic_publish");
|
|
329
358
|
await publishOperation(operationPath, publication);
|
|
330
|
-
return {
|
|
359
|
+
return {
|
|
360
|
+
outcome: "created",
|
|
361
|
+
world: await this.#currentSummary(publication),
|
|
362
|
+
preview,
|
|
363
|
+
};
|
|
331
364
|
}
|
|
332
365
|
catch (error) {
|
|
333
366
|
await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -398,6 +431,31 @@ export class FileNativeWorldStore {
|
|
|
398
431
|
await readPublicationAt(root);
|
|
399
432
|
return (await new FileNativeAuthorityV3(root).readHead()).head;
|
|
400
433
|
}
|
|
434
|
+
async withArchiveExport(worldId, action) {
|
|
435
|
+
assertIdentity(worldId, "World ID");
|
|
436
|
+
if ((this.#activeControlUsers.get(worldId)?.size ?? 0) > 0 ||
|
|
437
|
+
this.#archiveExports.has(worldId) ||
|
|
438
|
+
this.#openingWorldRevisions.has(worldId))
|
|
439
|
+
throw new FileNativeWorldCreationError("operation_conflict", "Wait for the current generation or world operation before exporting a backup");
|
|
440
|
+
this.#archiveExports.add(worldId);
|
|
441
|
+
try {
|
|
442
|
+
if (await this.readWorldRevisionLock(worldId))
|
|
443
|
+
throw new FileNativeWorldCreationError("operation_conflict", "Please apply or discard the world revision before exporting a backup");
|
|
444
|
+
return await action();
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
this.#archiveExports.delete(worldId);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async archiveNeedsFreshContext(worldId) {
|
|
451
|
+
assertIdentity(worldId, "World ID");
|
|
452
|
+
return ((await readOptionalJson(join(this.#worldsRoot, worldId, "runtime", "archive-import.json"))) !== null ||
|
|
453
|
+
(await this.playTimeline.currentContextRequiresFreshContext(worldId)));
|
|
454
|
+
}
|
|
455
|
+
async completeArchiveFreshContext(worldId) {
|
|
456
|
+
assertIdentity(worldId, "World ID");
|
|
457
|
+
await rm(join(this.#worldsRoot, worldId, "runtime", "archive-import.json"), { force: true });
|
|
458
|
+
}
|
|
401
459
|
async currentHeadOperationId(worldId) {
|
|
402
460
|
assertIdentity(worldId, "World ID");
|
|
403
461
|
await this.ensureCurrentStorage(worldId);
|
|
@@ -431,7 +489,8 @@ export class FileNativeWorldStore {
|
|
|
431
489
|
}
|
|
432
490
|
async acquireWorldRevisionLock(worldId, epochId) {
|
|
433
491
|
if ((this.#activeControlUsers.get(worldId)?.size ?? 0) > 0 ||
|
|
434
|
-
this.#openingWorldRevisions.has(worldId)
|
|
492
|
+
this.#openingWorldRevisions.has(worldId) ||
|
|
493
|
+
this.#archiveExports.has(worldId))
|
|
435
494
|
return { kind: "busy" };
|
|
436
495
|
this.#openingWorldRevisions.add(worldId);
|
|
437
496
|
try {
|
|
@@ -587,6 +646,8 @@ export class FileNativeWorldStore {
|
|
|
587
646
|
freezeControl(worldId, operationId) {
|
|
588
647
|
assertIdentity(worldId, "World ID");
|
|
589
648
|
assertIdentity(operationId, "operation ID");
|
|
649
|
+
if (this.#archiveExports.has(worldId))
|
|
650
|
+
throw new FileNativeWorldCreationError("operation_conflict", "A world backup is being exported; wait for it to finish");
|
|
590
651
|
if (this.#openingWorldRevisions.has(worldId))
|
|
591
652
|
throw new FileNativeWorldCreationError("operation_conflict", "World control is locked while a world revision opens");
|
|
592
653
|
const users = this.#activeControlUsers.get(worldId) ?? new Set();
|
|
@@ -796,32 +857,51 @@ export class FileNativeWorldStore {
|
|
|
796
857
|
if (input.requestDiscriminator !== undefined)
|
|
797
858
|
assertIdentity(input.requestDiscriminator, "Fork request discriminator");
|
|
798
859
|
if (input.sourceHead !== "genesis" &&
|
|
860
|
+
input.sourceHead !== "current" &&
|
|
799
861
|
!/^commit:[1-9][0-9]*$/u.test(input.sourceHead))
|
|
800
862
|
throw new TypeError("Source endpoint is invalid");
|
|
801
|
-
|
|
863
|
+
if (input.targetName !== undefined && !validWorldName(input.targetName))
|
|
864
|
+
throw new TypeError("Target world name is invalid");
|
|
865
|
+
if (input.publish === false && input.targetWorldRoot === undefined)
|
|
866
|
+
throw new TypeError("An unpublished closure requires a target directory");
|
|
867
|
+
const target = input.targetDataRoot === undefined
|
|
868
|
+
? this
|
|
869
|
+
: new FileNativeWorldStore(input.targetDataRoot);
|
|
870
|
+
const operationPath = input.identityPrefix === "import"
|
|
871
|
+
? join(target.#operationsRoot, `import-${operationDigest(input.operationId)}.json`)
|
|
872
|
+
: target.#derivationOperationPath(input.operationId);
|
|
802
873
|
const requestFingerprint = derivationRequestFingerprint(input);
|
|
803
|
-
const worldId = `world-${operationDigest(
|
|
804
|
-
const finalRoot =
|
|
805
|
-
|
|
874
|
+
const worldId = `world-${operationDigest(`${input.identityPrefix ?? "derive"}:${input.operationId}`).slice(0, 24)}`;
|
|
875
|
+
const finalRoot = input.publish === false
|
|
876
|
+
? resolve(input.targetWorldRoot)
|
|
877
|
+
: join(target.#worldsRoot, worldId);
|
|
878
|
+
const previous = input.publish === false
|
|
879
|
+
? null
|
|
880
|
+
: await readOptionalJson(operationPath);
|
|
806
881
|
if (previous !== null) {
|
|
807
|
-
await
|
|
808
|
-
if (!(await derivationPublicationMatches(join(
|
|
882
|
+
await target.ensureCurrentStorage(previous.worldId);
|
|
883
|
+
if (!(await derivationPublicationMatches(join(target.#worldsRoot, previous.worldId), previous, input, requestFingerprint)))
|
|
809
884
|
throw new FileNativeWorldCreationError("operation_conflict", "The same fork operation ID is bound to a different fork payload");
|
|
810
885
|
return {
|
|
811
886
|
outcome: "derived",
|
|
812
|
-
world: await
|
|
887
|
+
world: await target.#currentSummary(previous),
|
|
888
|
+
worldRoot: finalRoot,
|
|
813
889
|
};
|
|
814
890
|
}
|
|
815
891
|
const alreadyPublished = await readOptionalJson(join(finalRoot, publicationFile));
|
|
816
892
|
if (alreadyPublished !== null) {
|
|
817
|
-
|
|
893
|
+
if (input.publish !== false)
|
|
894
|
+
await target.ensureCurrentStorage(alreadyPublished.worldId);
|
|
818
895
|
if (!(await derivationPublicationMatches(finalRoot, alreadyPublished, input, requestFingerprint)))
|
|
819
896
|
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
820
|
-
|
|
821
|
-
|
|
897
|
+
if (input.publish !== false) {
|
|
898
|
+
await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
899
|
+
await publishJson(operationPath, alreadyPublished);
|
|
900
|
+
}
|
|
822
901
|
return {
|
|
823
902
|
outcome: "derived",
|
|
824
|
-
world: await
|
|
903
|
+
world: await readWorldSummaryAt(finalRoot, alreadyPublished),
|
|
904
|
+
worldRoot: finalRoot,
|
|
825
905
|
};
|
|
826
906
|
}
|
|
827
907
|
await this.ensureCurrentStorage(input.sourceWorldId);
|
|
@@ -830,24 +910,37 @@ export class FileNativeWorldStore {
|
|
|
830
910
|
const sourcePublication = await readPublicationAt(sourceRoot);
|
|
831
911
|
const sourceSummary = await readWorldSummaryAt(sourceRoot, sourcePublication);
|
|
832
912
|
const sourceAuthority = new FileNativeAuthorityV3(sourceRoot);
|
|
913
|
+
const sourceHead = input.sourceHead === "current"
|
|
914
|
+
? (await sourceAuthority.readHead()).head
|
|
915
|
+
: input.sourceHead;
|
|
916
|
+
if (input.requireMaterialized) {
|
|
917
|
+
const authorityHead = await sourceAuthority.readHead();
|
|
918
|
+
const materialized = await readMaterializedCheckpoint(sourceRoot);
|
|
919
|
+
if (materialized?.head !== authorityHead.head ||
|
|
920
|
+
materialized.commitDigest !== authorityHead.commitDigest)
|
|
921
|
+
throw new FileNativeWorldCreationError("inconsistent_materialization", "Please repair the world before exporting a backup");
|
|
922
|
+
}
|
|
833
923
|
const [sourceGenesis, control] = await Promise.all([
|
|
834
924
|
readGenesisForFork(sourceRoot, input.sourceWorldId),
|
|
835
925
|
readTree(join(sourceRoot, "control")),
|
|
836
926
|
]);
|
|
837
|
-
const staging = join(
|
|
838
|
-
await mkdir(
|
|
839
|
-
|
|
927
|
+
const staging = join(dirname(finalRoot), `.staging-${worldId}-${randomUUID()}`);
|
|
928
|
+
await mkdir(dirname(finalRoot), { recursive: true, mode: 0o700 });
|
|
929
|
+
if (input.publish !== false)
|
|
930
|
+
await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
840
931
|
try {
|
|
841
932
|
await mkdir(join(staging, "runtime"), { recursive: true });
|
|
842
933
|
const clonedHead = await sourceAuthority.clonePrefixTo({
|
|
843
934
|
targetWorldRoot: staging,
|
|
844
|
-
selectedHead:
|
|
935
|
+
selectedHead: sourceHead,
|
|
845
936
|
operationId: input.operationId,
|
|
846
937
|
});
|
|
847
938
|
const selected = await new FileNativeAuthorityV3(staging).recoverHeadResult();
|
|
848
939
|
await writeSurface(staging, "state", selected.state);
|
|
849
940
|
await writeSurface(staging, "control", control);
|
|
850
|
-
await PackageScriptPermissions.write(staging,
|
|
941
|
+
await PackageScriptPermissions.write(staging, input.resetPackageScriptPermissions
|
|
942
|
+
? []
|
|
943
|
+
: await PackageScriptPermissions.read(sourceRoot));
|
|
851
944
|
await WorldExtensionControls.copy(sourceRoot, staging);
|
|
852
945
|
await mkdir(join(staging, "history"), { recursive: true });
|
|
853
946
|
for (const file of historySurfaceFiles(selected.history))
|
|
@@ -871,22 +964,23 @@ export class FileNativeWorldStore {
|
|
|
871
964
|
history: genesis.history,
|
|
872
965
|
});
|
|
873
966
|
await writeJson(join(staging, "runtime", "additional-materials.json"), {
|
|
874
|
-
head:
|
|
967
|
+
head: sourceHead,
|
|
875
968
|
items: selected.additionalMaterials,
|
|
876
969
|
});
|
|
877
970
|
await writeJson(join(staging, "runtime", "materialized-head.json"), {
|
|
878
971
|
schemaVersion: 3,
|
|
879
|
-
head:
|
|
972
|
+
head: sourceHead,
|
|
880
973
|
sequence: clonedHead.sequence,
|
|
881
974
|
commitDigest: clonedHead.commitDigest,
|
|
882
975
|
...backgroundCount(selected.history),
|
|
883
976
|
});
|
|
884
977
|
await input.stageTarget?.({
|
|
978
|
+
sourceHead,
|
|
885
979
|
targetWorldRoot: staging,
|
|
886
980
|
targetWorldId: worldId,
|
|
887
981
|
binding: {
|
|
888
982
|
worldId,
|
|
889
|
-
parentHead:
|
|
983
|
+
parentHead: sourceHead,
|
|
890
984
|
files: [...selected.state, ...control].reduce((files, { path, contents }, index) => {
|
|
891
985
|
files[`${index < selected.state.length ? "state" : "control"}/${path}`] = contents;
|
|
892
986
|
return files;
|
|
@@ -899,23 +993,34 @@ export class FileNativeWorldStore {
|
|
|
899
993
|
])),
|
|
900
994
|
},
|
|
901
995
|
});
|
|
996
|
+
if (input.identityPrefix === "import" ||
|
|
997
|
+
(await this.archiveNeedsFreshContext(input.sourceWorldId)))
|
|
998
|
+
await writeJson(join(staging, "runtime", "archive-import.json"), {
|
|
999
|
+
schemaVersion: 1,
|
|
1000
|
+
});
|
|
902
1001
|
crashAtFileNativeAuthorityEdge("derivation_after_target_staging");
|
|
903
1002
|
const publication = {
|
|
904
1003
|
schemaVersion: 1,
|
|
905
1004
|
worldId,
|
|
906
|
-
title: derivedWorldName(sourceSummary.title),
|
|
1005
|
+
title: input.targetName ?? derivedWorldName(sourceSummary.title),
|
|
907
1006
|
parentEndpoint: "genesis",
|
|
908
1007
|
operationId: input.operationId,
|
|
909
1008
|
sourceFingerprint: requestFingerprint,
|
|
910
1009
|
};
|
|
911
1010
|
await writeJson(join(staging, publicationFile), publication);
|
|
1011
|
+
await updateWorldActivity(staging, sourceHead, selected.history, Date.now());
|
|
912
1012
|
await durableTree(staging);
|
|
913
1013
|
crashAtFileNativeAuthorityEdge("derivation_before_publish");
|
|
914
1014
|
await rename(staging, finalRoot);
|
|
915
|
-
await syncDirectory(
|
|
1015
|
+
await syncDirectory(dirname(finalRoot));
|
|
916
1016
|
crashAtFileNativeAuthorityEdge("derivation_after_publish");
|
|
917
|
-
|
|
918
|
-
|
|
1017
|
+
if (input.publish !== false)
|
|
1018
|
+
await publishJson(operationPath, publication);
|
|
1019
|
+
return {
|
|
1020
|
+
outcome: "derived",
|
|
1021
|
+
world: await readWorldSummaryAt(finalRoot, publication),
|
|
1022
|
+
worldRoot: finalRoot,
|
|
1023
|
+
};
|
|
919
1024
|
}
|
|
920
1025
|
catch (error) {
|
|
921
1026
|
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -923,8 +1028,13 @@ export class FileNativeWorldStore {
|
|
|
923
1028
|
if (published !== null) {
|
|
924
1029
|
if (!(await derivationPublicationMatches(finalRoot, published, input, requestFingerprint)))
|
|
925
1030
|
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
926
|
-
|
|
927
|
-
|
|
1031
|
+
if (input.publish !== false)
|
|
1032
|
+
await publishJson(operationPath, published);
|
|
1033
|
+
return {
|
|
1034
|
+
outcome: "derived",
|
|
1035
|
+
world: await readWorldSummaryAt(finalRoot, published),
|
|
1036
|
+
worldRoot: finalRoot,
|
|
1037
|
+
};
|
|
928
1038
|
}
|
|
929
1039
|
throw error;
|
|
930
1040
|
}
|
|
@@ -1285,12 +1395,13 @@ export class FileNativeWorldStore {
|
|
|
1285
1395
|
});
|
|
1286
1396
|
crashAtFileNativeAuthorityEdge("after_acceptance_prepared");
|
|
1287
1397
|
await authorityStore.publishPrepared(preparedAppend);
|
|
1398
|
+
const acceptedAt = Date.now();
|
|
1288
1399
|
crashAtFileNativeAuthorityEdge("after_commit_acceptance");
|
|
1289
1400
|
await publishJson(this.#operationOutcomePath(input.operationId), pending);
|
|
1290
1401
|
try {
|
|
1291
1402
|
const recovered = await authorityStore.recover(commit.head);
|
|
1292
1403
|
await assertPendingMaterializationCompatible(root, pending);
|
|
1293
|
-
await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered);
|
|
1404
|
+
await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered, acceptedAt);
|
|
1294
1405
|
crashAtFileNativeAuthorityEdge("after_materialization");
|
|
1295
1406
|
const outcome = { ...pending, outcome: "committed" };
|
|
1296
1407
|
await publishJson(this.#operationOutcomePath(input.operationId), outcome);
|
|
@@ -1792,7 +1903,7 @@ function isPlayHistoryAppendInput(value) {
|
|
|
1792
1903
|
(value.role === "player" || value.role === "narrator") &&
|
|
1793
1904
|
typeof value.exactText === "string");
|
|
1794
1905
|
}
|
|
1795
|
-
async function materializeRecoveredEndpoint(root, authority, endpoint) {
|
|
1906
|
+
async function materializeRecoveredEndpoint(root, authority, endpoint, lastActivityAt = null) {
|
|
1796
1907
|
if (authority.head !== endpoint.head)
|
|
1797
1908
|
throw new FileNativeWorldCreationError("world_corrupt", "Recovered endpoint does not match the Authority head to materialize");
|
|
1798
1909
|
await replaceMaterializedTree(join(root, "state"), endpoint.state);
|
|
@@ -1810,6 +1921,7 @@ async function materializeRecoveredEndpoint(root, authority, endpoint) {
|
|
|
1810
1921
|
commitDigest: authority.commitDigest,
|
|
1811
1922
|
...backgroundCount(endpoint.history),
|
|
1812
1923
|
});
|
|
1924
|
+
await updateWorldActivity(root, authority.head, endpoint.history, lastActivityAt);
|
|
1813
1925
|
}
|
|
1814
1926
|
async function replaceMaterializedTree(root, files) {
|
|
1815
1927
|
const next = new Map();
|
|
@@ -2065,14 +2177,25 @@ async function readGenesisForFork(worldRoot, worldId) {
|
|
|
2065
2177
|
async function readPublicationAt(root) {
|
|
2066
2178
|
return readJson(join(root, publicationFile));
|
|
2067
2179
|
}
|
|
2068
|
-
async function readWorldSummaryAt(root, publication) {
|
|
2180
|
+
async function readWorldSummaryAt(root, publication, serializeRebuild) {
|
|
2069
2181
|
const published = publication ?? (await readPublicationAt(root));
|
|
2182
|
+
const { lastActivityAt, excerpt, playerTurns } = await readWorldActivity(root, serializeRebuild);
|
|
2183
|
+
const summary = {
|
|
2184
|
+
...toSummary(published),
|
|
2185
|
+
title: await readWorldTitleAt(root, published),
|
|
2186
|
+
lastActivityAt,
|
|
2187
|
+
excerpt,
|
|
2188
|
+
playerTurns,
|
|
2189
|
+
};
|
|
2190
|
+
return summary;
|
|
2191
|
+
}
|
|
2192
|
+
async function readWorldTitleAt(root, published) {
|
|
2070
2193
|
const metadata = await readOptionalJson(join(root, localMetadataFile));
|
|
2071
2194
|
if (metadata === null)
|
|
2072
|
-
return
|
|
2195
|
+
return published.title;
|
|
2073
2196
|
if (!isWorldLocalMetadata(metadata) || metadata.worldId !== published.worldId)
|
|
2074
2197
|
throw new FileNativeWorldCreationError("world_corrupt", "Local world shell is corrupt");
|
|
2075
|
-
return
|
|
2198
|
+
return metadata.name;
|
|
2076
2199
|
}
|
|
2077
2200
|
async function readJson(path) {
|
|
2078
2201
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -2111,6 +2234,16 @@ function derivationRequestFingerprint(input) {
|
|
|
2111
2234
|
sourceHead: input.sourceHead,
|
|
2112
2235
|
hostPresetId: input.hostPresetId,
|
|
2113
2236
|
requestDiscriminator: input.requestDiscriminator ?? null,
|
|
2237
|
+
...(input.retainCompleteTimeline ? { retainCompleteTimeline: true } : {}),
|
|
2238
|
+
...(input.targetName === undefined &&
|
|
2239
|
+
input.identityPrefix === undefined &&
|
|
2240
|
+
!input.resetPackageScriptPermissions
|
|
2241
|
+
? {}
|
|
2242
|
+
: {
|
|
2243
|
+
targetName: input.targetName ?? null,
|
|
2244
|
+
identityPrefix: input.identityPrefix ?? "derive",
|
|
2245
|
+
resetPackageScriptPermissions: input.resetPackageScriptPermissions ?? false,
|
|
2246
|
+
}),
|
|
2114
2247
|
}));
|
|
2115
2248
|
}
|
|
2116
2249
|
async function derivationPublicationMatches(worldRoot, publication, input, requestFingerprint) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { FileNativeAuthorityV3 } from "./FileNativeAuthorityV3.js";
|
|
5
|
+
import { fileNativeHistoryMessageIdFromProjectionPath } from "./FileNativeHistoryProjection.js";
|
|
6
|
+
/** Read only the small head/cache on a hit; repair misses from the local projection. */
|
|
7
|
+
export async function readWorldActivity(root, serializeRebuild = (rebuild) => rebuild()) {
|
|
8
|
+
const head = await currentHead(root);
|
|
9
|
+
const cached = await readCache(root);
|
|
10
|
+
if (cached?.head === head)
|
|
11
|
+
return cached;
|
|
12
|
+
return serializeRebuild(async () => {
|
|
13
|
+
const latestHead = await currentHead(root);
|
|
14
|
+
const latestCache = await readCache(root);
|
|
15
|
+
if (latestCache?.head === latestHead)
|
|
16
|
+
return latestCache;
|
|
17
|
+
const empty = {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
head: latestHead,
|
|
20
|
+
lastActivityAt: null,
|
|
21
|
+
excerpt: null,
|
|
22
|
+
playerTurns: 0,
|
|
23
|
+
};
|
|
24
|
+
// An accepted endpoint may still be awaiting recovery. Never label its old
|
|
25
|
+
// materialized history as a valid cache for the new endpoint.
|
|
26
|
+
const materialized = await readJson(join(root, "runtime/materialized-head.json"));
|
|
27
|
+
if (!isRecord(materialized) || materialized.head !== latestHead)
|
|
28
|
+
return empty;
|
|
29
|
+
const entries = await readdir(join(root, "history"), {
|
|
30
|
+
withFileTypes: true,
|
|
31
|
+
});
|
|
32
|
+
const messages = entries
|
|
33
|
+
.filter((entry) => entry.isFile())
|
|
34
|
+
.map((entry) => ({
|
|
35
|
+
path: entry.name,
|
|
36
|
+
id: fileNativeHistoryMessageIdFromProjectionPath(entry.name),
|
|
37
|
+
}))
|
|
38
|
+
.filter((message) => message.id !== null)
|
|
39
|
+
.sort((left, right) => {
|
|
40
|
+
const a = left.path.split("-");
|
|
41
|
+
const b = right.path.split("-");
|
|
42
|
+
return Number(a[0]) - Number(b[0]) || Number(a[1]) - Number(b[1]);
|
|
43
|
+
});
|
|
44
|
+
const narrator = messages.findLast(({ id }) => id.endsWith(".narrator"));
|
|
45
|
+
const activity = {
|
|
46
|
+
...empty,
|
|
47
|
+
excerpt: narrator === undefined
|
|
48
|
+
? null
|
|
49
|
+
: excerpt(await readFile(join(root, "history", narrator.path), "utf8")),
|
|
50
|
+
playerTurns: messages.filter(({ id }) => id.endsWith(".player")).length,
|
|
51
|
+
};
|
|
52
|
+
await publishCache(root, activity);
|
|
53
|
+
return activity;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Best effort: activity is never part of accepting or recovering Authority. */
|
|
57
|
+
export async function updateWorldActivity(root, head, history, lastActivityAt) {
|
|
58
|
+
const narrator = history.findLast(({ role }) => role === "narrator");
|
|
59
|
+
// Recovery cannot reconstruct a clock time, but can retain a matching cache.
|
|
60
|
+
const previous = lastActivityAt === null ? await readCache(root) : null;
|
|
61
|
+
await publishCache(root, {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
head,
|
|
64
|
+
lastActivityAt: lastActivityAt ??
|
|
65
|
+
(previous?.head === head ? previous.lastActivityAt : null),
|
|
66
|
+
excerpt: narrator === undefined ? null : excerpt(narrator.exactText),
|
|
67
|
+
playerTurns: history.filter(({ role }) => role === "player").length,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function excerpt(text) {
|
|
71
|
+
const singleLine = text.replace(/[\r\n\u2028\u2029]+/gu, " ").trim();
|
|
72
|
+
return singleLine === ""
|
|
73
|
+
? null
|
|
74
|
+
: Array.from(singleLine).slice(0, 80).join("");
|
|
75
|
+
}
|
|
76
|
+
async function currentHead(root) {
|
|
77
|
+
try {
|
|
78
|
+
return (await new FileNativeAuthorityV3(root).readHead()).head;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!isMissing(error))
|
|
82
|
+
throw error;
|
|
83
|
+
// Listing a released layout must not trigger an Authority migration/replay.
|
|
84
|
+
const checkpoint = await readJson(join(root, "runtime/materialized-head.json"));
|
|
85
|
+
return isRecord(checkpoint) && typeof checkpoint.head === "string"
|
|
86
|
+
? checkpoint.head
|
|
87
|
+
: "genesis";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readCache(root) {
|
|
91
|
+
const value = await readJson(join(root, "runtime/activity.json")).catch(() => null);
|
|
92
|
+
return isRecord(value) &&
|
|
93
|
+
Object.keys(value).length === 5 &&
|
|
94
|
+
value.schemaVersion === 1 &&
|
|
95
|
+
typeof value.head === "string" &&
|
|
96
|
+
/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.head) &&
|
|
97
|
+
(value.lastActivityAt === null ||
|
|
98
|
+
(typeof value.lastActivityAt === "number" &&
|
|
99
|
+
Number.isSafeInteger(value.lastActivityAt) &&
|
|
100
|
+
value.lastActivityAt >= 0 &&
|
|
101
|
+
Number.isFinite(new Date(value.lastActivityAt).valueOf()))) &&
|
|
102
|
+
(value.excerpt === null ||
|
|
103
|
+
(typeof value.excerpt === "string" &&
|
|
104
|
+
Array.from(value.excerpt).length <= 80 &&
|
|
105
|
+
!/[\r\n\u2028\u2029]/u.test(value.excerpt))) &&
|
|
106
|
+
typeof value.playerTurns === "number" &&
|
|
107
|
+
Number.isSafeInteger(value.playerTurns) &&
|
|
108
|
+
value.playerTurns >= 0
|
|
109
|
+
? value
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
async function readJson(path) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (isMissing(error) || error instanceof SyntaxError)
|
|
118
|
+
return null;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function publishCache(root, activity) {
|
|
123
|
+
const path = join(root, "runtime/activity.json");
|
|
124
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
125
|
+
try {
|
|
126
|
+
await writeFile(temporary, `${JSON.stringify(activity)}\n`, {
|
|
127
|
+
mode: 0o600,
|
|
128
|
+
});
|
|
129
|
+
await rename(temporary, path);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.warn("Could not write world activity cache", root, error);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
function isMissing(error) {
|
|
142
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
143
|
+
}
|
|
@@ -6,6 +6,7 @@ import fastifyStatic from "@fastify/static";
|
|
|
6
6
|
import Fastify, { LogController, } from "fastify";
|
|
7
7
|
import { maxPortableContentArchiveBase64Characters } from "../protocol/contentTree.js";
|
|
8
8
|
import { parseV1Envelope, V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
|
|
9
|
+
import { WorldArchiveError } from "../runtime/archive/WorldArchiveZip.js";
|
|
9
10
|
const runtimeRequestBodyLimit = maxPortableContentArchiveBase64Characters + 64 * 1024;
|
|
10
11
|
const playCallChainStreamContentType = "application/x-ndjson";
|
|
11
12
|
class RuntimeLogController extends LogController {
|
|
@@ -81,6 +82,53 @@ export async function createServer(input) {
|
|
|
81
82
|
done(null, payload);
|
|
82
83
|
});
|
|
83
84
|
registerConversationStream(server, input.runtime);
|
|
85
|
+
// Keep uploads as a backpressured stream with their own archive-size limit.
|
|
86
|
+
// Fastify's JSON parser and content-package body limit never buffer this route.
|
|
87
|
+
await server.register((archives, _options, done) => {
|
|
88
|
+
archives.addContentTypeParser("application/zip", (_request, payload, done) => done(null, payload));
|
|
89
|
+
archives.post("/api/runtime/v1/world-archives", async (request, reply) => {
|
|
90
|
+
try {
|
|
91
|
+
return await input.runtime.worldArchives.upload(request.body);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof WorldArchiveError)
|
|
95
|
+
return reply
|
|
96
|
+
.status(error.code === "limit_exceeded" ? 413 : 400)
|
|
97
|
+
.send({ error: { code: error.code, message: error.message } });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
archives.get("/api/runtime/v1/world-archives/:archiveId", async (request, reply) => {
|
|
102
|
+
try {
|
|
103
|
+
const archive = await input.runtime.worldArchives.download(request.params.archiveId);
|
|
104
|
+
reply.raw.once("finish", () => {
|
|
105
|
+
void archive.cleanup().catch(() => undefined);
|
|
106
|
+
});
|
|
107
|
+
return reply
|
|
108
|
+
.type("application/zip")
|
|
109
|
+
.header("Content-Length", archive.metadata.bytes)
|
|
110
|
+
.header("Content-Disposition", `attachment; filename="world.narraeon-world.zip"; filename*=UTF-8''${encodeURIComponent(archive.metadata.fileName)}`)
|
|
111
|
+
.send(archive.stream);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof WorldArchiveError)
|
|
115
|
+
return reply
|
|
116
|
+
.status(400)
|
|
117
|
+
.send({ error: { code: error.code, message: error.message } });
|
|
118
|
+
if (error instanceof Error &&
|
|
119
|
+
"code" in error &&
|
|
120
|
+
error.code === "ENOENT")
|
|
121
|
+
return reply.status(404).send({
|
|
122
|
+
error: {
|
|
123
|
+
code: "invalid_archive",
|
|
124
|
+
message: "The archive download is unavailable",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
done();
|
|
131
|
+
});
|
|
84
132
|
server.get("/health", () => ({
|
|
85
133
|
status: "ok",
|
|
86
134
|
protocol: "narraeon.runtime/v1",
|