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
@@ -0,0 +1,353 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
+ export class WorldTurnLedgerError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = "WorldTurnLedgerError";
9
+ this.code = code;
10
+ }
11
+ }
12
+ const headFile = join("runtime", "head.json");
13
+ const turnsDirectory = join("runtime", "turns");
14
+ function turnFileName(sequence) {
15
+ return join(turnsDirectory, `${String(sequence).padStart(8, "0")}.json`);
16
+ }
17
+ function assertSafePath(relativePath) {
18
+ const normalized = normalize(relativePath);
19
+ if (isAbsolute(relativePath) ||
20
+ normalized.startsWith(`..${sep}`) ||
21
+ normalized === ".." ||
22
+ normalized.length === 0 ||
23
+ normalized === headFile ||
24
+ normalized === turnsDirectory ||
25
+ normalized.startsWith(turnsDirectory + sep)) {
26
+ throw new WorldTurnLedgerError("invalid_path", `世界文件路径不合法:${relativePath}`);
27
+ }
28
+ }
29
+ async function writeFileAtomic(absolute, content) {
30
+ await mkdir(dirname(absolute), { recursive: true });
31
+ const temporary = `${absolute}.${randomUUID()}.tmp`;
32
+ await writeFile(temporary, content, "utf8");
33
+ await rename(temporary, absolute);
34
+ }
35
+ async function readTextOrNull(absolute) {
36
+ try {
37
+ return await readFile(absolute, "utf8");
38
+ }
39
+ catch (error) {
40
+ if (isMissing(error))
41
+ return null;
42
+ throw error;
43
+ }
44
+ }
45
+ function isMissing(error) {
46
+ return (typeof error === "object" &&
47
+ error !== null &&
48
+ "code" in error &&
49
+ error.code === "ENOENT");
50
+ }
51
+ export async function initializeLedger(root) {
52
+ forgetLedger(root);
53
+ await writeFileAtomic(join(root, headFile), `${JSON.stringify({ sequence: 0 }, null, 2)}\n`);
54
+ }
55
+ export async function readHead(root) {
56
+ const text = await readTextOrNull(join(root, headFile));
57
+ if (text === null) {
58
+ throw new WorldTurnLedgerError("no_head", "世界缺少轮账本端点");
59
+ }
60
+ const parsed = JSON.parse(text);
61
+ if (typeof parsed.sequence !== "number" ||
62
+ !Number.isInteger(parsed.sequence) ||
63
+ parsed.sequence < 0) {
64
+ throw new WorldTurnLedgerError("corrupt", "世界轮账本端点格式不正确");
65
+ }
66
+ return parsed.sequence;
67
+ }
68
+ async function publishHead(root, sequence) {
69
+ await writeFileAtomic(join(root, headFile), `${JSON.stringify({ sequence }, null, 2)}\n`);
70
+ }
71
+ async function readAllRecords(root) {
72
+ let names;
73
+ try {
74
+ names = await readdir(join(root, turnsDirectory));
75
+ }
76
+ catch (error) {
77
+ if (isMissing(error))
78
+ return [];
79
+ throw error;
80
+ }
81
+ const records = [];
82
+ for (const name of names) {
83
+ if (!name.endsWith(".json"))
84
+ continue;
85
+ const text = await readFile(join(root, turnsDirectory, name), "utf8");
86
+ records.push(JSON.parse(text));
87
+ }
88
+ return records.sort((left, right) => left.sequence - right.sequence);
89
+ }
90
+ // 被某个 rewind 丢弃的轮是 (restores, rewind) 之间的位置;它们可能仍在磁盘上,
91
+ // 直到清理完成,但读取时一律忽略。
92
+ function viewOf(head, records) {
93
+ const byPosition = new Map();
94
+ for (const record of records) {
95
+ if (record.sequence <= head)
96
+ byPosition.set(record.sequence, record);
97
+ }
98
+ const discarded = new Set();
99
+ for (const record of byPosition.values()) {
100
+ if (record.restoresSequence === undefined)
101
+ continue;
102
+ for (let position = record.restoresSequence + 1; position < record.sequence; position += 1) {
103
+ discarded.add(position);
104
+ }
105
+ }
106
+ return { head, byPosition, discarded };
107
+ }
108
+ // 进程内视图缓存,按端点失效。一个数据目录只被一个进程操作(ADR-0044),
109
+ // 所以 head 相同即记录相同:任何改变可见记录的写入都会先推进 head。
110
+ // 只有 beginTurn 与 recoverIncomplete 会在 head 之上写入或删除记录,它们不影响视图
111
+ // (视图只包含序号不大于 head 的记录,且 recoverIncomplete 本身会在结束时失效缓存)。
112
+ const viewCache = new Map();
113
+ async function loadView(root) {
114
+ const head = await readHead(root);
115
+ const cached = viewCache.get(resolve(root));
116
+ if (cached?.head === head)
117
+ return cached;
118
+ const view = viewOf(head, await readAllRecords(root));
119
+ viewCache.set(resolve(root), view);
120
+ return view;
121
+ }
122
+ // 世界目录被删除、归档导入或分叉发布到已存在路径之前调用:下一次读取从磁盘重建视图。
123
+ export function forgetLedger(root) {
124
+ viewCache.delete(resolve(root));
125
+ }
126
+ // 把一个端点解析到它的逻辑位置:rewind 位置等价于它所恢复的位置。
127
+ function resolvePosition(view, sequence) {
128
+ if (!Number.isInteger(sequence) || sequence < 0 || sequence > view.head) {
129
+ throw new WorldTurnLedgerError("unreachable", `端点 ${sequence} 不在当前账本范围内`);
130
+ }
131
+ let position = sequence;
132
+ for (;;) {
133
+ if (position === 0)
134
+ return 0;
135
+ if (view.discarded.has(position)) {
136
+ throw new WorldTurnLedgerError("unreachable", `端点 ${sequence} 已被回退丢弃`);
137
+ }
138
+ const record = view.byPosition.get(position);
139
+ if (!record) {
140
+ throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
141
+ }
142
+ if (record.mode !== "rewind")
143
+ return position;
144
+ if (record.restoresSequence === undefined) {
145
+ throw new WorldTurnLedgerError("corrupt", `第 ${position} 轮缺少回退目标`);
146
+ }
147
+ position = record.restoresSequence;
148
+ }
149
+ }
150
+ // 从 sequence 之后的全部可见改动倒推:每个路径取最早一次修改前像。
151
+ function overlayAfter(view, sequence) {
152
+ const overlay = new Map();
153
+ for (let position = sequence + 1; position <= view.head; position += 1) {
154
+ if (view.discarded.has(position))
155
+ continue;
156
+ const record = view.byPosition.get(position);
157
+ if (!record) {
158
+ throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
159
+ }
160
+ if (record.mode === "rewind")
161
+ continue;
162
+ for (const file of record.files) {
163
+ if (!overlay.has(file.path))
164
+ overlay.set(file.path, file.before);
165
+ }
166
+ }
167
+ return overlay;
168
+ }
169
+ async function captureBefore(root, paths) {
170
+ const files = [];
171
+ for (const path of paths) {
172
+ assertSafePath(path);
173
+ files.push({ path, before: await readTextOrNull(join(root, path)) });
174
+ }
175
+ return files;
176
+ }
177
+ // 第一步:写入本轮记录(序号为 head + 1)。记录原子落盘,之后任何一步崩溃都可由 recoverIncomplete 回滚。
178
+ export async function beginTurn(root, input) {
179
+ const head = await readHead(root);
180
+ const record = {
181
+ sequence: head + 1,
182
+ mode: input.mode,
183
+ createdAt: input.createdAt ?? new Date().toISOString(),
184
+ ...(input.restoresSequence === undefined
185
+ ? {}
186
+ : { restoresSequence: input.restoresSequence }),
187
+ ...(input.payload === undefined ? {} : { payload: input.payload }),
188
+ files: await captureBefore(root, input.changes.map((change) => change.path)),
189
+ };
190
+ await writeFileAtomic(join(root, turnFileName(record.sequence)), `${JSON.stringify(record, null, 2)}\n`);
191
+ return record;
192
+ }
193
+ // 第二步:逐文件原子替换(写临时文件后 rename;删除即 rm)。
194
+ export async function applyChanges(root, changes) {
195
+ for (const change of changes) {
196
+ assertSafePath(change.path);
197
+ const absolute = join(root, change.path);
198
+ if (change.content === null) {
199
+ await rm(absolute, { force: true });
200
+ }
201
+ else {
202
+ await writeFileAtomic(absolute, change.content);
203
+ }
204
+ }
205
+ }
206
+ // 第三步:发布端点。只有这一步之后,本轮才算完成。
207
+ export async function commitTurnHead(root, record) {
208
+ await publishHead(root, record.sequence);
209
+ }
210
+ export async function appendTurn(root, input) {
211
+ const record = await beginTurn(root, input);
212
+ await applyChanges(root, input.changes);
213
+ await commitTurnHead(root, record);
214
+ return record;
215
+ }
216
+ // 启动时调用:序号大于 head 的记录说明上一次推进未完成,逆序恢复其修改前像。
217
+ // 被丢弃的轮记录(读取时已忽略)在这里清理。返回是否回滚了未完成的轮。
218
+ export async function recoverIncomplete(root) {
219
+ const head = await readHead(root);
220
+ const records = await readAllRecords(root);
221
+ const incomplete = records
222
+ .filter((record) => record.sequence > head)
223
+ .sort((left, right) => right.sequence - left.sequence);
224
+ for (const record of incomplete) {
225
+ for (const file of [...record.files].reverse()) {
226
+ assertSafePath(file.path);
227
+ const absolute = join(root, file.path);
228
+ if (file.before === null) {
229
+ await rm(absolute, { force: true });
230
+ }
231
+ else {
232
+ await writeFileAtomic(absolute, file.before);
233
+ }
234
+ }
235
+ await rm(join(root, turnFileName(record.sequence)), { force: true });
236
+ }
237
+ const view = viewOf(head, records);
238
+ for (const position of view.discarded) {
239
+ await rm(join(root, turnFileName(position)), { force: true });
240
+ }
241
+ forgetLedger(root);
242
+ return incomplete.length > 0;
243
+ }
244
+ export async function listTurns(root) {
245
+ const view = await loadView(root);
246
+ const turns = [];
247
+ for (let position = 1; position <= view.head; position += 1) {
248
+ if (view.discarded.has(position))
249
+ continue;
250
+ const record = view.byPosition.get(position);
251
+ if (!record) {
252
+ throw new WorldTurnLedgerError("missing_record", `缺少第 ${position} 轮记录`);
253
+ }
254
+ turns.push(record);
255
+ }
256
+ return turns;
257
+ }
258
+ // 把端点解析为它所表示的逻辑位置(rewind 位置等价于它恢复的位置)。
259
+ export async function resolveEndpoint(root, sequence) {
260
+ return resolvePosition(await loadView(root), sequence);
261
+ }
262
+ // 第 sequence 轮记录文件的 sha256;0 轮没有记录,返回 null。
263
+ export async function readTurnDigest(root, sequence) {
264
+ if (sequence === 0)
265
+ return null;
266
+ const text = await readFile(join(root, turnFileName(sequence)), "utf8");
267
+ return createHash("sha256").update(text).digest("hex");
268
+ }
269
+ // 读取端点 sequence 处某个文件的内容;不存在返回 null。
270
+ export async function readFileAt(root, sequence, relativePath) {
271
+ assertSafePath(relativePath);
272
+ const view = await loadView(root);
273
+ const position = resolvePosition(view, sequence);
274
+ const overlay = overlayAfter(view, position);
275
+ if (overlay.has(relativePath))
276
+ return overlay.get(relativePath) ?? null;
277
+ return readTextOrNull(join(root, relativePath));
278
+ }
279
+ // 读取端点 sequence 处 dirs 下的全部文件(相对路径 → 内容)。
280
+ export async function readTreeAt(root, sequence, dirs) {
281
+ const view = await loadView(root);
282
+ const position = resolvePosition(view, sequence);
283
+ const overlay = overlayAfter(view, position);
284
+ const tree = new Map();
285
+ for (const dir of dirs) {
286
+ assertSafePath(dir);
287
+ for (const path of await listFiles(root, dir)) {
288
+ if (!overlay.has(path)) {
289
+ const text = await readTextOrNull(join(root, path));
290
+ if (text !== null)
291
+ tree.set(path, text);
292
+ }
293
+ }
294
+ }
295
+ for (const [path, before] of overlay) {
296
+ const inDir = dirs.some((dir) => path === dir || path.startsWith(dir + "/"));
297
+ if (!inDir || before === null)
298
+ continue;
299
+ tree.set(path, before);
300
+ }
301
+ return tree;
302
+ }
303
+ async function listFiles(root, dir) {
304
+ const found = [];
305
+ const visit = async (relative) => {
306
+ let entries;
307
+ try {
308
+ entries = await readdir(join(root, relative), { withFileTypes: true });
309
+ }
310
+ catch (error) {
311
+ if (isMissing(error))
312
+ return;
313
+ throw error;
314
+ }
315
+ for (const entry of entries) {
316
+ const child = `${relative}/${entry.name}`;
317
+ if (entry.isDirectory()) {
318
+ await visit(child);
319
+ }
320
+ else if (entry.isFile() && !entry.name.endsWith(".tmp")) {
321
+ found.push(child);
322
+ }
323
+ }
324
+ };
325
+ await visit(dir);
326
+ return found;
327
+ }
328
+ // 回退到端点 sequence:追加一轮 rewind(前像为回退前的文件),之后丢弃 (sequence, head] 的记录。
329
+ // 已经处于 sequence 的端点是空操作。返回新轮记录,或 null。
330
+ export async function rewindTo(root, sequence) {
331
+ const view = await loadView(root);
332
+ const target = resolvePosition(view, sequence);
333
+ if (target === view.head)
334
+ return null;
335
+ const overlay = overlayAfter(view, target);
336
+ const changes = [];
337
+ for (const [path, before] of overlay) {
338
+ const current = await readTextOrNull(join(root, path));
339
+ if (current === before)
340
+ continue;
341
+ changes.push({ path, content: before });
342
+ }
343
+ const record = await appendTurn(root, {
344
+ mode: "rewind",
345
+ changes,
346
+ restoresSequence: sequence,
347
+ });
348
+ // 端点已发布,丢弃的记录即可删除;失败可忽略,读取已把它们当作不可达,下次回退或恢复会再清理。
349
+ for (let position = target + 1; position <= view.head; position += 1) {
350
+ await rm(join(root, turnFileName(position)), { force: true }).catch(() => undefined);
351
+ }
352
+ return record;
353
+ }
@@ -111,7 +111,6 @@ export class FileNativeWorldRevisionStore {
111
111
  if (session.worldId !== worldId)
112
112
  throw new Error("World-revision conversation belongs to another world");
113
113
  await rm(this.#sessionPath(sessionId));
114
- await syncDirectory(this.#sessionsRoot);
115
114
  });
116
115
  }
117
116
  async #readEpoch(epochId) {
@@ -247,7 +246,7 @@ function validateSession(value) {
247
246
  /[\r\n\0]/u.test(value.worldTitle) ||
248
247
  typeof value.epochId !== "string" ||
249
248
  (value.locale !== "en" && value.locale !== "zh-CN") ||
250
- !["ready", "running", "interrupted"].includes(String(value.runStatus)) ||
249
+ !["ready", "running", "paused", "interrupted"].includes(String(value.runStatus)) ||
251
250
  !finite(value.createdAt) ||
252
251
  !finite(value.updatedAt) ||
253
252
  value.updatedAt < value.createdAt ||
@@ -637,27 +636,16 @@ async function publishJson(path, value) {
637
636
  const handle = await open(temporary, "wx", 0o600);
638
637
  try {
639
638
  await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
640
- await handle.sync();
641
639
  }
642
640
  finally {
643
641
  await handle.close();
644
642
  }
645
643
  await rename(temporary, path);
646
- await syncDirectory(dirname(path));
647
644
  }
648
645
  finally {
649
646
  await rm(temporary, { force: true });
650
647
  }
651
648
  }
652
- async function syncDirectory(path) {
653
- const handle = await open(path, "r");
654
- try {
655
- await handle.sync();
656
- }
657
- finally {
658
- await handle.close();
659
- }
660
- }
661
649
  function count(value) {
662
650
  return Number.isSafeInteger(value) && Number(value) >= 0;
663
651
  }
@@ -1,7 +1,7 @@
1
1
  import { authoringRequestPreviews } from "../authoring/AuthoringRequestSnapshot.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { emptyAggregatedModelUsage } from "../../protocol/modelUsage.js";
4
- import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, } from "../authoring/ModelToolConversation.js";
4
+ import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
5
5
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
6
6
  import { equalModelHostBinding } from "../model/ModelHost.js";
7
7
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
@@ -215,6 +215,34 @@ export class WorldRevisionSession {
215
215
  });
216
216
  return prepared.run ?? this.#view(prepared.session);
217
217
  }
218
+ /** Continues a paused conversation within the revision it paused in. */
219
+ async resume(input) {
220
+ const requestId = requiredRequestId(input.requestId);
221
+ const prepared = await this.#mutate(async () => {
222
+ let session = await this.#store.readSession(input.sessionId);
223
+ assertWorld(session, input.worldId);
224
+ session = await this.#recoverIfNeeded(session);
225
+ if (session.completedRequestIds.includes(requestId))
226
+ return { session, run: null };
227
+ const running = this.#conversation.running(session.sessionId);
228
+ if (running !== null) {
229
+ if (running.requestId !== requestId)
230
+ throw new Error("Another message in this world-revision conversation is still running");
231
+ return { session, run: running.promise };
232
+ }
233
+ await this.#assertNoRunningConversation(input.worldId, session.sessionId);
234
+ const epoch = await this.#workspace.active(input.worldId);
235
+ if (epoch?.epochId !== session.epochId)
236
+ throw new WorldRevisionWorkspaceError("The paused conversation belongs to a revision that is no longer active; send a new message instead");
237
+ const host = await this.#bindExistingModelHost(session.modelBinding);
238
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
239
+ throw new Error("No saved model connection matches this world-revision conversation");
240
+ resumePausedAuthoringConversation(session);
241
+ await this.#store.saveSession(session);
242
+ return { session, run: this.#startRun(session, host, requestId) };
243
+ });
244
+ return prepared.run ?? this.#view(prepared.session);
245
+ }
218
246
  async preview(worldId, sessionId) {
219
247
  const session = sessionId === undefined ? null : await this.#store.readSession(sessionId);
220
248
  if (session !== null)
@@ -317,7 +345,6 @@ export class WorldRevisionSession {
317
345
  },
318
346
  view: (next) => this.#view(next),
319
347
  failure: {
320
- exchangeLimit: "World revision exceeded 64 model exchanges for one user message",
321
348
  cancelled: "The world-revision response was cancelled. Settled worktree changes were retained.",
322
349
  interrupted: "The world-revision response was interrupted",
323
350
  },
@@ -0,0 +1,52 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { inspect } from "node:util";
4
+ import { resolveAppPaths } from "../runtime/config/appPaths.js";
5
+ import { parseWebPort, startWebServer } from "./startWebServer.js";
6
+ // Mobile entry: started inside the Android app's Node runtime. The native shell
7
+ // owns the process lifecycle, so this module never registers signal handlers and
8
+ // never calls process.exit(); a startup failure is logged and the native side
9
+ // reports it.
10
+ const paths = resolveAppPaths(process.env);
11
+ const crashLogPath = join(paths.logRoot, "crash", "node-errors.log");
12
+ // Node shares the Android app process: an uncaught error would make Node call
13
+ // exit() and close the whole app. Record the error and keep serving instead; each
14
+ // Runtime write is atomic, so a failed request does not leave a half-written file.
15
+ process.on("uncaughtException", (error, origin) => {
16
+ recordCrash(origin, error);
17
+ });
18
+ process.on("unhandledRejection", (reason) => {
19
+ recordCrash("unhandledRejection", reason);
20
+ });
21
+ process.on("exit", (code) => {
22
+ recordCrash("exit", `process exiting with code ${code}`);
23
+ });
24
+ try {
25
+ const staticRoot = process.env.NARRAEON_STATIC_ROOT?.trim();
26
+ if (!staticRoot)
27
+ throw new Error("NARRAEON_STATIC_ROOT is required for the mobile entry");
28
+ const port = parseWebPort(process.env.NARRAEON_PORT);
29
+ const server = await startWebServer({
30
+ paths,
31
+ staticRoot,
32
+ host: "127.0.0.1",
33
+ port,
34
+ });
35
+ const address = server.server.address();
36
+ const listeningPort = typeof address === "object" && address !== null ? address.port : port;
37
+ process.stdout.write(`NARRAEON_READY ${listeningPort}\n`);
38
+ }
39
+ catch (error) {
40
+ recordCrash("startup", error);
41
+ }
42
+ function recordCrash(kind, value) {
43
+ const text = `${new Date().toISOString()} [${kind}] ${typeof value === "string" ? value : inspect(value, { depth: 4 })}\n`;
44
+ process.stderr.write(text);
45
+ try {
46
+ mkdirSync(join(paths.logRoot, "crash"), { recursive: true, mode: 0o700 });
47
+ appendFileSync(crashLogPath, text, { encoding: "utf8", mode: 0o600 });
48
+ }
49
+ catch {
50
+ // The stderr copy above still reaches logcat and the native stderr log.
51
+ }
52
+ }
@@ -1,22 +1,29 @@
1
+ import { acquireDataRootLock } from "../runtime/config/dataRootLock.js";
1
2
  import { createRuntime } from "./createRuntime.js";
2
3
  import { createServer } from "./createServer.js";
3
4
  export const defaultWebPort = 4317;
4
5
  export const defaultWebHost = "127.0.0.1";
5
6
  export const containerWebHost = "0.0.0.0";
6
7
  export async function startWebServer(input) {
7
- const runtime = await createRuntime(input.paths);
8
- const server = await createServer({
9
- runtime,
10
- staticRoot: input.staticRoot,
11
- port: input.port,
12
- ...(input.logger === undefined ? {} : { logger: input.logger }),
13
- });
8
+ const lock = await acquireDataRootLock(input.paths.dataRoot);
9
+ let server;
14
10
  try {
11
+ const runtime = await createRuntime(input.paths);
12
+ server = await createServer({
13
+ runtime,
14
+ staticRoot: input.staticRoot,
15
+ port: input.port,
16
+ ...(input.logger === undefined ? {} : { logger: input.logger }),
17
+ });
18
+ server.addHook("onClose", () => lock.release());
15
19
  await server.listen({ host: input.host, port: input.port });
16
20
  return server;
17
21
  }
18
22
  catch (error) {
19
- await server.close().catch(() => undefined);
23
+ if (server !== undefined)
24
+ await server.close().catch(() => undefined);
25
+ else
26
+ await lock.release();
20
27
  throw error;
21
28
  }
22
29
  }