narraeon 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,8 @@
1
+ import { completedAuthoringEdit, planAuthoringEdit, revertAuthoringChanges, } from "../authoring/AuthoringEditPlan.js";
2
+ import { canRetryAuthoringDispatch, checkpointNextAuthoringDispatch, recoverAuthoringDispatch, } from "../authoring/AuthoringRetry.js";
1
3
  import { authoringRequestPreviews } from "../authoring/AuthoringRequestSnapshot.js";
4
+ import { readGameplay } from "../../protocol/gameplay.js";
5
+ import { isDeepStrictEqual } from "node:util";
2
6
  import { emptyAggregatedModelUsage } from "../../protocol/modelUsage.js";
3
7
  import { InvalidContentTreeError, } from "../content/ContentWorkspace.js";
4
8
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
@@ -10,12 +14,31 @@ import { summarizeStoredSettingImprovementSession, } from "./FileNativeSettingIm
10
14
  import { SettingAuthoringTransaction, settingImprovementRuntimeContract, settingImprovementToolDefinitions, authoringPreloadAuthorization, buildAuthoringPreload, contentPackageAuthoringSurface, } from "./SettingAuthoringTransaction.js";
11
15
  class RecoveryWriteConflictError extends Error {
12
16
  }
17
+ const liveConversations = new Map();
18
+ const releaseConversation = new FinalizationRegistry(({ scope, reference }) => {
19
+ if (liveConversations.get(scope) === reference)
20
+ liveConversations.delete(scope);
21
+ });
13
22
  export class SettingImprovementRollbackError extends Error {
14
23
  constructor(message, options) {
15
24
  super(message, options);
16
25
  this.name = "SettingImprovementRollbackError";
17
26
  }
18
27
  }
28
+ export class SettingMessageEditError extends Error {
29
+ reason;
30
+ paths;
31
+ constructor(reason, paths) {
32
+ super(reason === "conflict"
33
+ ? `Cannot revert changed files: ${paths.join(", ")}`
34
+ : reason === "invalid_tree"
35
+ ? `Reverting would make these files invalid: ${paths.join(", ")}`
36
+ : `Cannot attribute older file changes to this message: ${paths.join(", ")}`);
37
+ this.name = "SettingMessageEditError";
38
+ this.reason = reason;
39
+ this.paths = paths;
40
+ }
41
+ }
19
42
  export class SettingImprovementSession {
20
43
  #store;
21
44
  #content;
@@ -25,7 +48,7 @@ export class SettingImprovementSession {
25
48
  #bindExistingModelHost;
26
49
  #bindPlayPreset;
27
50
  #preview;
28
- #conversation = new ModelToolConversation();
51
+ #conversation;
29
52
  #changed;
30
53
  #mutationTail = Promise.resolve();
31
54
  constructor(input) {
@@ -35,6 +58,17 @@ export class SettingImprovementSession {
35
58
  /* No observers attached. */
36
59
  });
37
60
  this.#store = input.store;
61
+ let shared = liveConversations.get(input.store.sessionScope)?.deref();
62
+ if (shared === undefined) {
63
+ shared = new ModelToolConversation();
64
+ const reference = new WeakRef(shared);
65
+ liveConversations.set(input.store.sessionScope, reference);
66
+ releaseConversation.register(shared, {
67
+ scope: input.store.sessionScope,
68
+ reference,
69
+ });
70
+ }
71
+ this.#conversation = shared;
38
72
  this.#content = input.content;
39
73
  this.#compiler = input.compiler;
40
74
  this.#locale = input.locale;
@@ -123,7 +157,13 @@ export class SettingImprovementSession {
123
157
  const stored = await this.#store.read(sessionId);
124
158
  assertPackage(stored, packageId);
125
159
  await this.#recoverIfNeeded(stored);
126
- await this.#store.deleteSession(packageId, sessionId);
160
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(packageId);
161
+ try {
162
+ await this.#store.deleteSession(packageId, sessionId);
163
+ }
164
+ finally {
165
+ lease.release();
166
+ }
127
167
  });
128
168
  return this.overview(packageId);
129
169
  }
@@ -186,11 +226,12 @@ export class SettingImprovementSession {
186
226
  session = await this.#store.read(input.continuation.sessionId);
187
227
  assertPackage(session, input.packageId);
188
228
  }
189
- return this.#startRequest(session, requestId, async (next) => {
229
+ return this.#startRequest(session, requestId, async (next, lease) => {
190
230
  if (next.runStatus === "paused")
191
231
  throw new Error("Resume the paused authoring request before sending another message");
192
- const { playPreset, bootstrap, preload } = await this.#compileCurrent(input.packageId, next.modelBinding);
193
- next.schemaVersion = 3;
232
+ const { playPreset, bootstrap, preload } = await this.#compilePackage(lease.package, next.modelBinding);
233
+ if (next.schemaVersion !== 5)
234
+ next.schemaVersion = 4;
194
235
  next.requests ??= [];
195
236
  next.requests.push({
196
237
  requestId,
@@ -200,20 +241,136 @@ export class SettingImprovementSession {
200
241
  preload: structuredClone(preload.record),
201
242
  });
202
243
  next.authorization = authoringPreloadAuthorization(next.authorization, preload.record);
244
+ delete next.retryDispatch;
203
245
  appendAuthoringUserMessage(next, message, requestId);
246
+ checkpointNextAuthoringDispatch(next, "dispatching");
204
247
  if (next.modelBinding.provider === "google_interactions")
205
248
  next.googleOutcomeUnknown = false;
206
249
  });
207
250
  });
208
251
  return prepared.run ?? this.#view(prepared.session);
209
252
  }
253
+ async editMessage(input) {
254
+ const message = requiredMessage(input.message);
255
+ const requestId = requiredRequestId(input.requestId);
256
+ if (input.filePolicy !== "keep" && input.filePolicy !== "revert")
257
+ throw new Error("Invalid file policy");
258
+ const prepared = await this.#mutate(async () => {
259
+ let session = await this.#store.read(input.sessionId);
260
+ assertPackage(session, input.packageId);
261
+ session = await this.#recoverIfNeeded(session);
262
+ const edit = {
263
+ requestId,
264
+ messageId: input.messageId,
265
+ message,
266
+ filePolicy: input.filePolicy,
267
+ };
268
+ if (completedAuthoringEdit(session, edit))
269
+ return { session, run: null };
270
+ const running = this.#conversation.running(session.sessionId);
271
+ if (running !== null) {
272
+ if (running.requestId !== requestId)
273
+ throw new Error("Another message in this setting-improvement conversation is still running");
274
+ return { session, run: running.promise };
275
+ }
276
+ if (session.googleOutcomeUnknown)
277
+ throw new Error("The Google request outcome is unknown; start a fresh model context.");
278
+ const host = await this.#bindExistingModelHost(session.modelBinding);
279
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
280
+ throw new Error("No saved model connection matches this setting-improvement conversation");
281
+ const { itemIndex, tail, next } = planAuthoringEdit(session, edit);
282
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(input.packageId);
283
+ try {
284
+ if (!isDeepStrictEqual(await this.#store.read(session.sessionId), session))
285
+ throw new Error("The authoring conversation changed; reload it before editing");
286
+ if (input.filePolicy === "revert" &&
287
+ session.legacyDraft !== null &&
288
+ (session.legacyDraft.outcome === "applied" ||
289
+ session.legacyDraft.outcome === "apply_outcome_unknown") &&
290
+ session.legacyDraft.changes.length > 0 &&
291
+ !afterLegacyBoundary(session, itemIndex))
292
+ throw new SettingMessageEditError("unattributed", [
293
+ ...new Set(session.legacyDraft.changes.map(({ path }) => path)),
294
+ ].sort());
295
+ const beforeFiles = lease.package.files;
296
+ const reverted = input.filePolicy === "revert"
297
+ ? revertAuthoringChanges(beforeFiles, tail)
298
+ : { files: beforeFiles, conflicts: [] };
299
+ if (reverted.conflicts.length > 0)
300
+ throw new SettingMessageEditError("conflict", reverted.conflicts);
301
+ const afterFiles = reverted.files;
302
+ this.#content.validateCurrentTreeContentPackage(afterFiles);
303
+ if (input.filePolicy === "revert") {
304
+ const existingIssues = new Set(this.#content
305
+ .inspectCurrentTreeContentPackage(beforeFiles)
306
+ .issues.map((issue) => JSON.stringify(issue)));
307
+ const newIssues = this.#content
308
+ .inspectCurrentTreeContentPackage(afterFiles)
309
+ .issues.filter((issue) => !existingIssues.has(JSON.stringify(issue)));
310
+ if (newIssues.length > 0)
311
+ throw new SettingMessageEditError("invalid_tree", [...new Set(newIssues.map(({ path }) => path))].sort());
312
+ }
313
+ const { playPreset, bootstrap, preload } = await this.#compilePackage({
314
+ ...lease.package,
315
+ files: afterFiles,
316
+ gameplay: readGameplay(afterFiles),
317
+ }, session.modelBinding);
318
+ next.schemaVersion = 5;
319
+ delete next.pendingEdit;
320
+ next.requests.push({
321
+ requestId,
322
+ modelItemStart: itemIndex,
323
+ bootstrap,
324
+ playPreset: structuredClone(playPreset),
325
+ preload: structuredClone(preload.record),
326
+ });
327
+ next.authorization = authoringPreloadAuthorization(null, preload.record);
328
+ appendAuthoringUserMessage(next, message, requestId);
329
+ checkpointNextAuthoringDispatch(next, "dispatching");
330
+ if (next.modelBinding.provider === "google_interactions")
331
+ next.googleOutcomeUnknown = false;
332
+ if (next.modelBinding.provider === "google_interactions" &&
333
+ host.previewAuthoringRequest !== undefined)
334
+ next.requests.at(-1).wireRequest = host.previewAuthoringRequest(authoringExchange(next, requestId));
335
+ session.schemaVersion = 5;
336
+ session.requests ??= [];
337
+ session.pendingEdit = {
338
+ beforeFingerprint: contentTreeFingerprint(beforeFiles),
339
+ afterFingerprint: contentTreeFingerprint(afterFiles),
340
+ afterFiles: structuredClone(afterFiles),
341
+ next,
342
+ };
343
+ await this.#store.preparePendingEdit(session);
344
+ this.#changed(input.packageId, true);
345
+ const committed = await this.#store.finishPendingEdit(session, lease);
346
+ this.#changed(input.packageId, true);
347
+ return {
348
+ session: committed,
349
+ run: this.#startRun(committed, host, requestId),
350
+ };
351
+ }
352
+ finally {
353
+ lease.release();
354
+ }
355
+ });
356
+ return prepared.run ?? this.#view(prepared.session);
357
+ }
210
358
  /** Continues a paused conversation with the frozen request it paused in. */
211
359
  async resume(input) {
212
360
  const requestId = requiredRequestId(input.requestId);
213
361
  const prepared = await this.#mutate(async () => {
214
362
  const session = await this.#store.read(input.sessionId);
215
363
  assertPackage(session, input.packageId);
216
- return this.#startRequest(session, requestId, (next) => resumePausedAuthoringConversation(next));
364
+ return this.#startRequest(session, requestId, (next) => {
365
+ if (canRetryAuthoringDispatch(next)) {
366
+ next.runStatus = "running";
367
+ next.activeRequestId = next.requests?.at(-1)?.requestId ?? null;
368
+ next.lastFailure = null;
369
+ next.updatedAt = Date.now();
370
+ }
371
+ else
372
+ resumePausedAuthoringConversation(next);
373
+ });
217
374
  });
218
375
  return prepared.run ?? this.#view(prepared.session);
219
376
  }
@@ -223,8 +380,11 @@ export class SettingImprovementSession {
223
380
  return { session, run: null };
224
381
  const running = this.#conversation.running(session.sessionId);
225
382
  if (running !== null) {
226
- if (running.requestId !== requestId)
383
+ if (running.requestId !== requestId) {
384
+ if (session.googleOutcomeUnknown)
385
+ throw new Error("The Google request outcome is unknown; start a fresh model context.");
227
386
  throw new Error("Another message in this setting-improvement conversation is still running");
387
+ }
228
388
  return { session, run: running.promise };
229
389
  }
230
390
  if (session.googleOutcomeUnknown)
@@ -232,13 +392,23 @@ export class SettingImprovementSession {
232
392
  const host = await this.#bindExistingModelHost(session.modelBinding);
233
393
  if (!equalModelHostBinding(session.modelBinding, host.binding()))
234
394
  throw new Error("No saved model connection matches this setting-improvement conversation");
235
- await prepare(session);
236
- if (session.modelBinding.provider === "google_interactions" &&
237
- host.previewAuthoringRequest !== undefined &&
238
- session.requests?.at(-1)?.requestId === requestId)
239
- session.requests.at(-1).wireRequest = host.previewAuthoringRequest(authoringExchange(session, requestId));
240
- await this.#store.save(session);
241
- return { session, run: this.#startRun(session, host, requestId) };
395
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(session.packageId);
396
+ try {
397
+ if (!isDeepStrictEqual(await this.#store.read(session.sessionId), session))
398
+ throw new Error("The authoring conversation changed; reload it before continuing");
399
+ if (session.schemaVersion === 3)
400
+ session.schemaVersion = 4;
401
+ await prepare(session, lease);
402
+ if (session.modelBinding.provider === "google_interactions" &&
403
+ host.previewAuthoringRequest !== undefined &&
404
+ session.requests?.at(-1)?.requestId === requestId)
405
+ session.requests.at(-1).wireRequest = host.previewAuthoringRequest(authoringExchange(session, requestId));
406
+ await this.#store.save(session);
407
+ return { session, run: this.#startRun(session, host, requestId) };
408
+ }
409
+ finally {
410
+ lease.release();
411
+ }
242
412
  }
243
413
  async preview(packageId, sessionId) {
244
414
  const session = sessionId === undefined ? null : await this.#store.read(sessionId);
@@ -254,9 +424,12 @@ export class SettingImprovementSession {
254
424
  return bootstrap;
255
425
  }
256
426
  async #compileCurrent(packageId, modelBinding) {
427
+ const package_ = await this.#content.readCurrentTreeContentPackage(packageId);
428
+ return this.#compilePackage(package_, modelBinding);
429
+ }
430
+ async #compilePackage(package_, modelBinding) {
257
431
  const playPreset = await this.#bindPlayPreset();
258
432
  const locale = this.#locale();
259
- const package_ = await this.#content.readCurrentTreeContentPackage(packageId);
260
433
  const preload = this.#preload(package_.files, modelBinding, playPreset, locale);
261
434
  const bootstrap = this.#compiler.compileSettingImprovement({
262
435
  contentPackageTitle: package_.title,
@@ -329,7 +502,8 @@ export class SettingImprovementSession {
329
502
  const now = Date.now();
330
503
  const sessionId = this.#store.createId();
331
504
  const session = {
332
- schemaVersion: 2,
505
+ schemaVersion: 4,
506
+ requests: [],
333
507
  sessionId,
334
508
  packageId,
335
509
  contentPackageTitle: package_.title,
@@ -366,6 +540,7 @@ export class SettingImprovementSession {
366
540
  session,
367
541
  host,
368
542
  requestId,
543
+ exchangeRequestId: session.activeRequestId ?? requestId,
369
544
  save: (next) => this.#store.save(next),
370
545
  recordGoogleHop: (id, hop) => this.#store.recordGoogleHop(session.sessionId, id, hop),
371
546
  settleToolResponse: async (next, assistantItemIndex, toolCalls) => {
@@ -377,10 +552,11 @@ export class SettingImprovementSession {
377
552
  cancelled: "The setting-improvement response was cancelled. Complete tool responses already published to the current tree were retained.",
378
553
  interrupted: "The setting-improvement response was interrupted",
379
554
  },
555
+ retrySupported: session.schemaVersion === 4 || session.schemaVersion === 5,
380
556
  });
381
557
  }
382
558
  async #settleToolResponse(session, assistantItemIndex, calls, active) {
383
- const lease = await this.#content.beginCurrentTreeContentPackageOperation(session.packageId);
559
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(session.packageId, { wait: true });
384
560
  try {
385
561
  const beforeFingerprint = contentTreeFingerprint(lease.package.files);
386
562
  session.pendingSettlement = {
@@ -420,6 +596,8 @@ export class SettingImprovementSession {
420
596
  }
421
597
  }
422
598
  this.#finalizeSettlement(session, prepared);
599
+ if (session.schemaVersion === 4 || session.schemaVersion === 5)
600
+ checkpointNextAuthoringDispatch(session, "dispatching");
423
601
  session.updatedAt = Date.now();
424
602
  await this.#store.save(session);
425
603
  return { revision: afterFingerprint, transaction };
@@ -454,9 +632,43 @@ export class SettingImprovementSession {
454
632
  async #recoverIfNeeded(original) {
455
633
  if (this.#conversation.isRunning(original.sessionId))
456
634
  return original;
635
+ if (original.pendingEdit !== undefined) {
636
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(original.packageId);
637
+ lease.release();
638
+ const recovered = await this.#store.read(original.sessionId);
639
+ if (recovered.pendingEdit !== undefined)
640
+ throw new Error("The pending authoring edit has no valid package recovery marker");
641
+ return this.#recoverIfNeeded(recovered);
642
+ }
457
643
  let expected = original;
458
644
  const session = structuredClone(original);
459
645
  const pendingAssistant = lastPendingAssistant(session);
646
+ if (session.runStatus === "running" &&
647
+ session.pendingSettlement === null &&
648
+ pendingAssistant === null) {
649
+ const lease = await this.#content.beginCurrentTreeContentPackageOperation(session.packageId);
650
+ let latest;
651
+ try {
652
+ latest = await this.#store.read(session.sessionId);
653
+ if (this.#conversation.isRunning(session.sessionId))
654
+ return latest;
655
+ if (isDeepStrictEqual(latest, original)) {
656
+ if (session.modelBinding.provider === "google_interactions" &&
657
+ session.googleOutcomeUnknown !== false)
658
+ session.googleOutcomeUnknown = true;
659
+ markInterruptedAfterRestart(session);
660
+ recoverAuthoringDispatch(session);
661
+ session.updatedAt = Date.now();
662
+ if (await this.#store.saveIfUnchanged(original, session))
663
+ return session;
664
+ latest = await this.#store.read(session.sessionId);
665
+ }
666
+ }
667
+ finally {
668
+ lease.release();
669
+ }
670
+ return this.#recoverIfNeeded(latest);
671
+ }
460
672
  if (session.pendingSettlement === null && pendingAssistant !== null) {
461
673
  this.#finalizeUnconfirmedToolResponse(session, pendingAssistant.itemIndex, "Runtime found a complete tool response without the persisted current-tree revision that authorized it. The calls were not replayed against a newer tree; read the current tree again before editing.");
462
674
  markInterruptedAfterRestart(session);
@@ -475,6 +687,7 @@ export class SettingImprovementSession {
475
687
  let fingerprint = contentTreeFingerprint(lease.package.files);
476
688
  const pending = session.pendingSettlement;
477
689
  let prepared = pending.phase === "publication_prepared" ? pending : null;
690
+ let settled = false;
478
691
  if (fingerprint === pending.beforeFingerprint) {
479
692
  const assistant = assistantAt(session, pending.assistantItemIndex);
480
693
  const transaction = this.#openTransaction(session, lease.package.files, fingerprint);
@@ -504,11 +717,20 @@ export class SettingImprovementSession {
504
717
  }
505
718
  else if (fingerprint === prepared.afterFingerprint) {
506
719
  this.#finalizeSettlement(session, prepared);
720
+ settled = true;
507
721
  }
508
722
  else {
509
723
  this.#finalizeUnconfirmedToolResponse(session, prepared.assistantItemIndex, "The content package no longer matches either the original or prepared revision for this tool response. No claimed change is being treated as authoritative; read the current tree again before editing.");
510
724
  }
511
725
  markInterruptedAfterRestart(session);
726
+ const request = session.requests?.at(-1);
727
+ if (settled &&
728
+ request !== undefined &&
729
+ session.modelBinding.provider !== "google_interactions") {
730
+ if (session.schemaVersion !== 5)
731
+ session.schemaVersion = 4;
732
+ checkpointNextAuthoringDispatch(session, "retryable");
733
+ }
512
734
  session.updatedAt = Date.now();
513
735
  if (!(await this.#store.saveIfUnchanged(expected, session)))
514
736
  throw new RecoveryWriteConflictError();
@@ -538,13 +760,6 @@ export class SettingImprovementSession {
538
760
  "The Google request outcome is unknown; start a fresh model context.";
539
761
  session.updatedAt = Date.now();
540
762
  }
541
- else if (session.runStatus === "running") {
542
- if (session.modelBinding.provider === "google_interactions" &&
543
- session.googleOutcomeUnknown !== false)
544
- session.googleOutcomeUnknown = true;
545
- markInterruptedAfterRestart(session);
546
- session.updatedAt = Date.now();
547
- }
548
763
  else {
549
764
  return session;
550
765
  }
@@ -601,6 +816,7 @@ export class SettingImprovementSession {
601
816
  updatedAt: session.updatedAt,
602
817
  },
603
818
  lastFailure: session.lastFailure,
819
+ canRetry: canRetryAuthoringDispatch(session),
604
820
  };
605
821
  }
606
822
  async #mutate(operation) {
@@ -764,6 +980,13 @@ function treeMatchesChangeBefore(files, change) {
764
980
  current.encoding === undefined &&
765
981
  current.contents === expected);
766
982
  }
983
+ function afterLegacyBoundary(session, itemIndex) {
984
+ const first = session.requests?.[0];
985
+ return (first !== undefined &&
986
+ itemIndex >= first.modelItemStart &&
987
+ (first.modelItemStart > 0 ||
988
+ session.editReceipts?.some((receipt) => receipt.requestId === first.requestId) === true));
989
+ }
767
990
  function restoreChangeBefore(files, change) {
768
991
  const restored = new Map(files.map((file) => [file.path, structuredClone(file)]));
769
992
  if (change.before === null)
@@ -4,7 +4,7 @@ import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
4
4
  import { validAuthoringRequests, } from "../authoring/AuthoringRequestSnapshot.js";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { readGoogleHops, writeGoogleHop } from "../model/GoogleHopJournal.js";
7
- import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
7
+ import { link, mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
8
8
  import { dirname, join, resolve } from "node:path";
9
9
  import { gameplayPath } from "../../protocol/gameplay.js";
10
10
  import { validGoogleInteractionsSteps } from "../model/ProviderExchangeState.js";
@@ -43,6 +43,9 @@ export class FileNativeWorldRevisionStore {
43
43
  this.#epochsRoot = join(root, "epochs");
44
44
  this.#sessionsRoot = join(root, "sessions");
45
45
  }
46
+ get sessionScope() {
47
+ return this.#sessionsRoot;
48
+ }
46
49
  createEpochId() {
47
50
  return `revision-${randomUUID()}`;
48
51
  }
@@ -114,9 +117,90 @@ export class FileNativeWorldRevisionStore {
114
117
  saveSession(session) {
115
118
  return this.#mutate(async () => {
116
119
  validateSession(session);
117
- await publishJson(this.#sessionPath(session.sessionId), session);
120
+ const path = this.#sessionPath(session.sessionId);
121
+ {
122
+ const source = await readFile(path).catch((error) => {
123
+ if (isNodeError(error) && error.code === "ENOENT")
124
+ return null;
125
+ throw error;
126
+ });
127
+ if (source !== null) {
128
+ const old = validateSession(JSON.parse(source.toString("utf8")));
129
+ if (old.schemaVersion === 3 && session.schemaVersion !== 3)
130
+ throw new Error("Cannot downgrade a migrated world-revision session");
131
+ if (old.schemaVersion !== 3) {
132
+ const backup = join(this.#sessionsRoot, ".legacy", `${session.sessionId}.json`);
133
+ await mkdir(dirname(backup), { recursive: true, mode: 0o700 });
134
+ const temporary = `${backup}.${randomUUID()}.tmp`;
135
+ try {
136
+ const handle = await open(temporary, "wx", 0o600);
137
+ try {
138
+ await handle.writeFile(source);
139
+ }
140
+ finally {
141
+ await handle.close();
142
+ }
143
+ try {
144
+ await link(temporary, backup);
145
+ }
146
+ catch (error) {
147
+ if (!isNodeError(error) || error.code !== "EEXIST")
148
+ throw error;
149
+ }
150
+ if (!(await readFile(backup)).equals(source))
151
+ throw new Error("World-revision legacy source backup does not match the original bytes");
152
+ }
153
+ finally {
154
+ await rm(temporary, { force: true });
155
+ }
156
+ session.schemaVersion = 3;
157
+ validateSession(session);
158
+ }
159
+ }
160
+ }
161
+ await publishJson(path, session);
118
162
  });
119
163
  }
164
+ async prepareEdit(input) {
165
+ validateEpoch(input.epoch);
166
+ validateSession(input.session);
167
+ if (input.epoch.worldId !== input.session.worldId ||
168
+ input.epoch.epochId !== input.session.epochId ||
169
+ !treeRevisionPattern.test(input.beforeRevision))
170
+ throw new Error("Invalid world-revision edit intent");
171
+ await publishJson(this.#editPath(input.epoch.epochId), input);
172
+ }
173
+ async readEdit(epochId) {
174
+ let value;
175
+ try {
176
+ value = JSON.parse(await readFile(this.#editPath(epochId), "utf8"));
177
+ }
178
+ catch (error) {
179
+ if (isNodeError(error) && error.code === "ENOENT")
180
+ return null;
181
+ throw error;
182
+ }
183
+ if (!isRecord(value) ||
184
+ !hasExactKeys(value, ["beforeRevision", "epoch", "session"]) ||
185
+ typeof value.beforeRevision !== "string" ||
186
+ !treeRevisionPattern.test(value.beforeRevision))
187
+ throw new Error("Invalid world-revision edit intent");
188
+ const epoch = validateEpoch(value.epoch);
189
+ const session = validateSession(value.session);
190
+ if (epoch.epochId !== epochId ||
191
+ epoch.worldId !== session.worldId ||
192
+ epoch.epochId !== session.epochId ||
193
+ epoch.lifecycle !== "active")
194
+ throw new Error("Invalid world-revision edit identity");
195
+ return { beforeRevision: value.beforeRevision, epoch, session };
196
+ }
197
+ async removeEdit(epochId) {
198
+ await rm(this.#editPath(epochId), { force: true });
199
+ }
200
+ #editPath(epochId) {
201
+ assertEpochId(epochId);
202
+ return join(this.#epochsRoot, `.edit-${epochId}.json`);
203
+ }
120
204
  async listSessions(worldId) {
121
205
  return this.#afterMutations(async () => {
122
206
  const entries = await readdir(this.#sessionsRoot, {
@@ -339,15 +423,51 @@ function validateSession(value) {
339
423
  "lastFailure",
340
424
  "authorization",
341
425
  "pendingSettlement",
342
- ], ["requests", "googleOutcomeUnknown"]) ||
343
- (value.schemaVersion !== 1 && value.schemaVersion !== 2) ||
426
+ ], [
427
+ "requests",
428
+ "googleOutcomeUnknown",
429
+ ...(value.schemaVersion === 3 ? ["retryDispatch", "editReceipts"] : []),
430
+ ]) ||
431
+ (value.schemaVersion !== 1 &&
432
+ value.schemaVersion !== 2 &&
433
+ value.schemaVersion !== 3) ||
434
+ (value.editReceipts !== undefined &&
435
+ !validEditReceipts(value.editReceipts)) ||
436
+ (value.retryDispatch !== undefined &&
437
+ (!isRecord(value.retryDispatch) ||
438
+ !hasExactKeys(value.retryDispatch, [
439
+ "requestId",
440
+ "exchange",
441
+ "modelItemCount",
442
+ "state",
443
+ ]) ||
444
+ !nonEmptyString(value.retryDispatch.requestId) ||
445
+ !count(value.retryDispatch.exchange) ||
446
+ !count(value.retryDispatch.modelItemCount) ||
447
+ !Array.isArray(value.requests) ||
448
+ !isRecord(value.requests.at(-1)) ||
449
+ value.requests.at(-1).requestId !==
450
+ value.retryDispatch.requestId ||
451
+ (value.runStatus !== "running" && value.runStatus !== "interrupted") ||
452
+ (value.retryDispatch.state === "retryable" &&
453
+ (!isRecord(value.modelBinding) ||
454
+ value.modelBinding.provider === "google_interactions")) ||
455
+ value.pendingSettlement !== null ||
456
+ !count(value.exchange) ||
457
+ (value.retryDispatch.exchange !== value.exchange &&
458
+ value.retryDispatch.exchange !== value.exchange + 1) ||
459
+ value.retryDispatch.modelItemCount !==
460
+ (Array.isArray(value.modelItems) ? value.modelItems.length : -1) ||
461
+ !["dispatching", "retryable", "blocked"].includes(String(value.retryDispatch.state)))) ||
344
462
  (value.schemaVersion === 1
345
463
  ? value.requests !== undefined
346
- : !validAuthoringRequests(value.requests, validPromptCompilation, {
347
- modelItems: value.modelItems,
348
- activeRequestId: value.activeRequestId,
349
- completedRequestIds: value.completedRequestIds,
350
- })) ||
464
+ : value.schemaVersion === 3 && value.requests === undefined
465
+ ? value.retryDispatch !== undefined
466
+ : !validAuthoringRequests(value.requests, validPromptCompilation, {
467
+ modelItems: value.modelItems,
468
+ activeRequestId: value.activeRequestId,
469
+ completedRequestIds: value.completedRequestIds,
470
+ })) ||
351
471
  typeof value.sessionId !== "string" ||
352
472
  !nonEmptyString(value.worldId) ||
353
473
  typeof value.worldTitle !== "string" ||
@@ -389,6 +509,31 @@ function validateSession(value) {
389
509
  assertEpochId(value.epochId);
390
510
  return structuredClone(value);
391
511
  }
512
+ function validEditReceipts(value) {
513
+ if (!Array.isArray(value))
514
+ return false;
515
+ const ids = new Set();
516
+ return value.every((entry) => {
517
+ if (!isRecord(entry) ||
518
+ !hasExactKeys(entry, [
519
+ "requestId",
520
+ "messageId",
521
+ "message",
522
+ "filePolicy",
523
+ ]) ||
524
+ typeof entry.requestId !== "string" ||
525
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u.test(entry.requestId) ||
526
+ ids.has(entry.requestId) ||
527
+ !nonEmptyString(entry.messageId) ||
528
+ typeof entry.message !== "string" ||
529
+ entry.message.trim().length === 0 ||
530
+ entry.message.length > 64 * 1024 ||
531
+ (entry.filePolicy !== "keep" && entry.filePolicy !== "revert"))
532
+ return false;
533
+ ids.add(entry.requestId);
534
+ return true;
535
+ });
536
+ }
392
537
  function validFiles(value) {
393
538
  if (!Array.isArray(value))
394
539
  return false;