narraeon 0.4.0 → 0.5.0
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/dist/node/protocol/appPreferences.js +25 -0
- package/dist/node/protocol/conversationObservation.js +67 -0
- package/dist/node/protocol/v1.js +186 -34
- package/dist/node/protocol/worldExtensions.js +16 -0
- package/dist/node/protocol/worldMaintenance.js +77 -0
- package/dist/node/protocol/worldPromptDiagnostics.js +1 -0
- package/dist/node/runtime/ConversationChanges.js +24 -0
- package/dist/node/runtime/V1Runtime.js +445 -121
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +326 -12
- package/dist/node/runtime/authoring/AuthoringRequestSnapshot.js +63 -0
- package/dist/node/runtime/authoring/ModelToolConversation.js +218 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +46 -17
- package/dist/node/runtime/content/ContentTreeFingerprint.js +13 -0
- package/dist/node/runtime/content/ContentWorkspace.js +43 -13
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +128 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +28 -10
- package/dist/node/runtime/content/PackageFollowups.js +80 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +19 -14
- package/dist/node/runtime/extension/PackageScriptPermissions.js +70 -0
- package/dist/node/runtime/extension/WorldExtensionControls.js +205 -0
- package/dist/node/runtime/extension/WorldExtensionRequests.js +64 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +21 -466
- package/dist/node/runtime/model/ModelConnectionStore.js +13 -0
- package/dist/node/runtime/model/ModelHost.js +31 -1
- package/dist/node/runtime/model/ModelHostStream.js +51 -5
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +55 -45
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +256 -238
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +118 -9
- package/dist/node/runtime/play/OrderedFollowups.js +71 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +180 -0
- package/dist/node/runtime/play/PlayCallChain.js +814 -61
- package/dist/node/runtime/play/PlayContinuity.js +88 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +205 -287
- package/dist/node/runtime/play/PlayFollowupRequests.js +34 -3
- package/dist/node/runtime/play/PlayPresetWorkbench.js +45 -54
- package/dist/node/runtime/play/PlayPromptRun.js +86 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +670 -93
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -21
- package/dist/node/runtime/prompt/PromptCompilationCodec.js +322 -0
- package/dist/node/runtime/prompt/WorldMaintenanceReport.js +251 -0
- package/dist/node/runtime/prompt/WorldMaterialCoverage.js +83 -0
- package/dist/node/runtime/prompt/WorldPromptDiagnostics.js +185 -0
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +1027 -0
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +1339 -0
- package/dist/node/runtime/setting/SettingImprovementSession.js +714 -0
- package/dist/node/runtime/world/FileNativeAuthorityV3.js +88 -1
- package/dist/node/runtime/world/FileNativeWorldStore.js +292 -27
- package/dist/node/runtime/world/PlayerViewBindings.js +30 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +27 -34
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +174 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +166 -96
- package/dist/node/runtime/world/WorldOperationCoordinator.js +103 -2
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +696 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +380 -0
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +709 -0
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +494 -0
- package/dist/node/server/ConversationStream.js +109 -0
- package/dist/node/server/createServer.js +5 -1
- package/dist/node/shared/artifact-payload-contract.js +123 -0
- package/dist/node/shared/default-play-prompts.js +25 -45
- package/dist/node/shared/default-preset-host.js +114 -138
- package/dist/node/shared/default-setting-improvement-prompt.js +52 -38
- package/dist/node/shared/display-regex.js +114 -0
- package/dist/node/shared/first-party-action-choices.js +1 -6
- package/dist/node/shared/first-party-player-view.js +1 -6
- package/dist/node/shared/ordered-author-prompts.js +64 -0
- package/dist/node/shared/ordered-followups.js +42 -0
- package/dist/node/shared/ordered-play-prompts.js +73 -0
- package/dist/web/assets/index-BXfelCi9.js +1207 -0
- package/dist/web/assets/index-BYnhUxlk.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/node/runtime/model/SettingAuthorStream.js +0 -21
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +0 -1731
- package/dist/web/assets/index-D8YKfmNG.js +0 -1192
- package/dist/web/assets/index-Xa0s35CJ.css +0 -1
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
|
|
3
|
+
import { readDeclaredWorldClock } from "../prompt/WorldMaintenanceReport.js";
|
|
4
|
+
import { WorldOperationClaimLostError } from "../world/WorldOperationCoordinator.js";
|
|
5
|
+
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
6
|
+
import { assertUsableWorldRevisionFiles, inspectWorldRevisionFiles, } from "./WorldRevisionAuthoringTransaction.js";
|
|
7
|
+
export class WorldRevisionWorkspaceError extends Error {
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = "WorldRevisionWorkspaceError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Owns the one persistent state/control worktree for a locked world. */
|
|
14
|
+
export class WorldRevisionWorkspace {
|
|
15
|
+
#store;
|
|
16
|
+
#worlds;
|
|
17
|
+
constructor(input) {
|
|
18
|
+
this.#store = input.store;
|
|
19
|
+
this.#worlds = input.worlds;
|
|
20
|
+
}
|
|
21
|
+
async open(worldId) {
|
|
22
|
+
const locked = await this.#worlds.readWorldRevisionLock(worldId);
|
|
23
|
+
if (locked !== null) {
|
|
24
|
+
const resumed = await this.#resumeLocked(locked);
|
|
25
|
+
return resumed.lifecycle === "active" ? resumed : this.open(worldId);
|
|
26
|
+
}
|
|
27
|
+
const epochId = this.#store.createEpochId();
|
|
28
|
+
const acquired = await this.#worlds.acquireWorldRevisionLock(worldId, epochId);
|
|
29
|
+
if (acquired.kind === "busy") {
|
|
30
|
+
const current = await this.#worlds.readWorldRevisionLock(worldId);
|
|
31
|
+
if (current === null)
|
|
32
|
+
throw new WorldRevisionWorkspaceError("Another world operation is still finishing; retry opening the revision");
|
|
33
|
+
const resumed = await this.#resumeLocked(current);
|
|
34
|
+
return resumed.lifecycle === "active" ? resumed : this.open(worldId);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return await this.#resumeLocked(acquired.handle);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await this.#worlds.releaseWorldRevisionLock(acquired.handle);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async active(worldId) {
|
|
45
|
+
const handle = await this.#worlds.readWorldRevisionLock(worldId);
|
|
46
|
+
if (handle === null)
|
|
47
|
+
return null;
|
|
48
|
+
const resumed = await this.#resumeLocked(handle);
|
|
49
|
+
return resumed.lifecycle === "active" ? resumed : null;
|
|
50
|
+
}
|
|
51
|
+
async latest(worldId) {
|
|
52
|
+
return (await this.#store.listEpochs(worldId))[0] ?? null;
|
|
53
|
+
}
|
|
54
|
+
async sealed(worldId) {
|
|
55
|
+
return (await this.#store.listEpochs(worldId)).flatMap((epoch) => {
|
|
56
|
+
if ((epoch.lifecycle !== "applied" && epoch.lifecycle !== "discarded") ||
|
|
57
|
+
epoch.finishedAt === null)
|
|
58
|
+
return [];
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
epochId: epoch.epochId,
|
|
62
|
+
worldId: epoch.worldId,
|
|
63
|
+
lifecycle: epoch.lifecycle,
|
|
64
|
+
baseHead: epoch.baseHead,
|
|
65
|
+
diff: fileDiff(epoch.baseFiles, epoch.files),
|
|
66
|
+
changes: structuredClone(epoch.changes),
|
|
67
|
+
createdAt: epoch.createdAt,
|
|
68
|
+
finishedAt: epoch.finishedAt,
|
|
69
|
+
appliedHead: epoch.apply?.committedHead ?? null,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async replace(input) {
|
|
75
|
+
const handle = revisionHandle(input);
|
|
76
|
+
return this.#withMutableEpoch(handle, async (epoch) => {
|
|
77
|
+
assertExpectedRevision(epoch, input.expectedRevision);
|
|
78
|
+
const files = cloneFiles(input.files);
|
|
79
|
+
assertUsableWorldRevisionFiles(files, epoch.baseFiles);
|
|
80
|
+
const changes = fileDiff(epoch.files, files);
|
|
81
|
+
if (changes.length === 0)
|
|
82
|
+
return epoch;
|
|
83
|
+
epoch.files = files;
|
|
84
|
+
epoch.revision = contentTreeFingerprint(files);
|
|
85
|
+
epoch.changes.push({
|
|
86
|
+
changeSetId: this.#store.createChangeSetId(),
|
|
87
|
+
source: "manual",
|
|
88
|
+
createdAt: Date.now(),
|
|
89
|
+
changes,
|
|
90
|
+
});
|
|
91
|
+
epoch.updatedAt = Date.now();
|
|
92
|
+
await this.#store.saveEpoch(epoch);
|
|
93
|
+
return epoch;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async publishAi(input) {
|
|
97
|
+
const handle = revisionHandle(input);
|
|
98
|
+
return this.#withMutableEpoch(handle, async (epoch) => {
|
|
99
|
+
if (epoch.revision === input.afterRevision) {
|
|
100
|
+
const ids = input.toolResults.flatMap(({ changeSetId }) => changeSetId === null ? [] : [changeSetId]);
|
|
101
|
+
if (ids.every((id) => epoch.changes.some(({ changeSetId }) => changeSetId === id)))
|
|
102
|
+
return epoch;
|
|
103
|
+
}
|
|
104
|
+
assertExpectedRevision(epoch, input.expectedRevision);
|
|
105
|
+
const files = cloneFiles(input.files);
|
|
106
|
+
assertUsableWorldRevisionFiles(files, epoch.baseFiles);
|
|
107
|
+
if (contentTreeFingerprint(files) !== input.afterRevision)
|
|
108
|
+
throw new WorldRevisionWorkspaceError("Prepared AI publication does not match its revision fingerprint");
|
|
109
|
+
for (const result of input.toolResults) {
|
|
110
|
+
if (result.isError ||
|
|
111
|
+
result.changeSetId === null ||
|
|
112
|
+
result.changes.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
epoch.changes.push({
|
|
115
|
+
changeSetId: result.changeSetId,
|
|
116
|
+
source: "ai",
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
changes: structuredClone(result.changes),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
epoch.files = files;
|
|
122
|
+
epoch.revision = input.afterRevision;
|
|
123
|
+
epoch.updatedAt = Date.now();
|
|
124
|
+
await this.#store.saveEpoch(epoch);
|
|
125
|
+
return epoch;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async rollback(input) {
|
|
129
|
+
const handle = revisionHandle(input);
|
|
130
|
+
return this.#withMutableEpoch(handle, async (epoch) => {
|
|
131
|
+
const set = epoch.changes.find(({ changeSetId }) => changeSetId === input.changeSetId);
|
|
132
|
+
const matches = set?.changes.filter(({ path }) => path === input.path);
|
|
133
|
+
const change = matches?.[0];
|
|
134
|
+
if (set === undefined || matches?.length !== 1 || change === undefined)
|
|
135
|
+
throw new WorldRevisionWorkspaceError("The requested file change does not exist in this revision epoch");
|
|
136
|
+
if (fileMatchesBefore(epoch.files, change))
|
|
137
|
+
return {
|
|
138
|
+
status: "already_rolled_back",
|
|
139
|
+
changeSetId: input.changeSetId,
|
|
140
|
+
path: input.path,
|
|
141
|
+
changes: [],
|
|
142
|
+
epoch,
|
|
143
|
+
};
|
|
144
|
+
const inverse = inverseDiff(epoch.files, change);
|
|
145
|
+
const files = restoreBefore(epoch.files, change);
|
|
146
|
+
assertUsableWorldRevisionFiles(files, epoch.baseFiles);
|
|
147
|
+
epoch.files = files;
|
|
148
|
+
epoch.revision = contentTreeFingerprint(files);
|
|
149
|
+
epoch.changes.push({
|
|
150
|
+
changeSetId: this.#store.createChangeSetId(),
|
|
151
|
+
source: "rollback",
|
|
152
|
+
createdAt: Date.now(),
|
|
153
|
+
changes: [inverse],
|
|
154
|
+
});
|
|
155
|
+
epoch.updatedAt = Date.now();
|
|
156
|
+
await this.#store.saveEpoch(epoch);
|
|
157
|
+
return {
|
|
158
|
+
status: "rolled_back",
|
|
159
|
+
changeSetId: input.changeSetId,
|
|
160
|
+
path: input.path,
|
|
161
|
+
changes: [inverse],
|
|
162
|
+
epoch,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async discard(input) {
|
|
167
|
+
const handle = revisionHandle(input);
|
|
168
|
+
const epoch = await this.#withMutableEpoch(handle, async (current) => {
|
|
169
|
+
current.lifecycle = "discarded";
|
|
170
|
+
current.finishedAt = Date.now();
|
|
171
|
+
current.updatedAt = current.finishedAt;
|
|
172
|
+
await this.#store.saveEpoch(current);
|
|
173
|
+
return current;
|
|
174
|
+
});
|
|
175
|
+
await this.#worlds.releaseWorldRevisionLock(handle);
|
|
176
|
+
return epoch;
|
|
177
|
+
}
|
|
178
|
+
async apply(input) {
|
|
179
|
+
const handle = revisionHandle(input);
|
|
180
|
+
let epoch = await this.#prepareApply(handle, input.expectedRevision);
|
|
181
|
+
const overallDiff = fileDiff(epoch.baseFiles, epoch.files);
|
|
182
|
+
if (overallDiff.length === 0) {
|
|
183
|
+
epoch = await this.#finishEmptyApply(handle);
|
|
184
|
+
await this.#worlds.releaseWorldRevisionLock(handle);
|
|
185
|
+
return epoch;
|
|
186
|
+
}
|
|
187
|
+
if (epoch.apply?.phase === "prepared") {
|
|
188
|
+
const binding = await this.#worlds.bindWorldRevision(handle);
|
|
189
|
+
const worldClock = readDeclaredWorldClock(WorldDocumentStore.open({
|
|
190
|
+
layout: "world_state",
|
|
191
|
+
files: epoch.files,
|
|
192
|
+
}));
|
|
193
|
+
const outcome = await this.#worlds.commitCorrection({
|
|
194
|
+
...(worldClock === undefined ? {} : { worldClock }),
|
|
195
|
+
operationId: epoch.apply.operationId,
|
|
196
|
+
worldId: epoch.worldId,
|
|
197
|
+
parentHead: epoch.baseHead,
|
|
198
|
+
nextMaterials: binding.additionalMaterials,
|
|
199
|
+
stateChanges: stateChanges(epoch.baseFiles, epoch.files),
|
|
200
|
+
worldRevisionLock: handle,
|
|
201
|
+
validationControl: controlFiles(epoch.files),
|
|
202
|
+
});
|
|
203
|
+
epoch = await this.#recordStateCommit(handle, outcome);
|
|
204
|
+
}
|
|
205
|
+
if (epoch.apply?.phase === "state_committed") {
|
|
206
|
+
await this.#worlds.replaceWorldRevisionControl({
|
|
207
|
+
handle,
|
|
208
|
+
expectedFingerprint: epoch.baseControlFingerprint,
|
|
209
|
+
files: controlFiles(epoch.files),
|
|
210
|
+
});
|
|
211
|
+
await this.#recordControlPublication(handle);
|
|
212
|
+
}
|
|
213
|
+
epoch = await this.#finishApply(handle);
|
|
214
|
+
await this.#worlds.releaseWorldRevisionLock(handle);
|
|
215
|
+
return epoch;
|
|
216
|
+
}
|
|
217
|
+
view(epoch, locked) {
|
|
218
|
+
const inspection = inspectWorldRevisionFiles(epoch.files, epoch.baseFiles);
|
|
219
|
+
return {
|
|
220
|
+
epochId: epoch.epochId,
|
|
221
|
+
worldId: epoch.worldId,
|
|
222
|
+
lifecycle: epoch.lifecycle,
|
|
223
|
+
locked,
|
|
224
|
+
baseHead: epoch.baseHead,
|
|
225
|
+
revision: epoch.revision,
|
|
226
|
+
files: cloneFiles(epoch.files),
|
|
227
|
+
diff: fileDiff(epoch.baseFiles, epoch.files),
|
|
228
|
+
diagnostics: structuredClone(inspection.diagnostics),
|
|
229
|
+
changes: structuredClone(epoch.changes),
|
|
230
|
+
createdAt: epoch.createdAt,
|
|
231
|
+
updatedAt: epoch.updatedAt,
|
|
232
|
+
finishedAt: epoch.finishedAt,
|
|
233
|
+
appliedHead: epoch.apply?.committedHead ?? null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async #resumeLocked(handle) {
|
|
237
|
+
let epoch = await this.#store.readEpochIfPresent(handle.epochId);
|
|
238
|
+
if (epoch === null) {
|
|
239
|
+
const binding = await this.#worlds.bindWorldRevision(handle);
|
|
240
|
+
const files = Object.entries(binding.files)
|
|
241
|
+
.map(([path, contents]) => ({ path, contents }))
|
|
242
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
243
|
+
assertUsableWorldRevisionFiles(files, files);
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
epoch = {
|
|
246
|
+
schemaVersion: 1,
|
|
247
|
+
epochId: handle.epochId,
|
|
248
|
+
worldId: handle.worldId,
|
|
249
|
+
lifecycle: "active",
|
|
250
|
+
baseHead: binding.parentHead,
|
|
251
|
+
baseControlFingerprint: controlFingerprint(controlFiles(files)),
|
|
252
|
+
baseFiles: cloneFiles(files),
|
|
253
|
+
files: cloneFiles(files),
|
|
254
|
+
revision: contentTreeFingerprint(files),
|
|
255
|
+
changes: [],
|
|
256
|
+
apply: null,
|
|
257
|
+
createdAt: now,
|
|
258
|
+
updatedAt: now,
|
|
259
|
+
finishedAt: null,
|
|
260
|
+
};
|
|
261
|
+
await this.#store.saveEpoch(epoch);
|
|
262
|
+
}
|
|
263
|
+
if (epoch.worldId !== handle.worldId)
|
|
264
|
+
throw new WorldRevisionWorkspaceError("World-revision lock points to another world's epoch");
|
|
265
|
+
if (epoch.lifecycle === "applying")
|
|
266
|
+
return this.apply({
|
|
267
|
+
worldId: epoch.worldId,
|
|
268
|
+
epochId: epoch.epochId,
|
|
269
|
+
expectedRevision: epoch.apply?.expectedRevision ?? epoch.revision,
|
|
270
|
+
});
|
|
271
|
+
if (epoch.lifecycle !== "active") {
|
|
272
|
+
await this.#worlds.releaseWorldRevisionLock(handle);
|
|
273
|
+
return epoch;
|
|
274
|
+
}
|
|
275
|
+
return epoch;
|
|
276
|
+
}
|
|
277
|
+
async #withMutableEpoch(handle, action) {
|
|
278
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
279
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
280
|
+
if (epoch.worldId !== handle.worldId || epoch.lifecycle !== "active")
|
|
281
|
+
throw new WorldRevisionWorkspaceError("World-revision epoch is no longer editable");
|
|
282
|
+
return action(epoch);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async #prepareApply(handle, expectedRevision) {
|
|
286
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
287
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
288
|
+
if (epoch.lifecycle === "applying")
|
|
289
|
+
return epoch;
|
|
290
|
+
if (epoch.lifecycle !== "active")
|
|
291
|
+
throw new WorldRevisionWorkspaceError("World-revision epoch is no longer applicable");
|
|
292
|
+
assertExpectedRevision(epoch, expectedRevision);
|
|
293
|
+
assertUsableWorldRevisionFiles(epoch.files, epoch.baseFiles);
|
|
294
|
+
epoch.lifecycle = "applying";
|
|
295
|
+
epoch.apply = {
|
|
296
|
+
operationId: `world-revision-${epoch.epochId}`,
|
|
297
|
+
expectedRevision,
|
|
298
|
+
phase: "prepared",
|
|
299
|
+
committedHead: null,
|
|
300
|
+
};
|
|
301
|
+
epoch.updatedAt = Date.now();
|
|
302
|
+
await this.#store.saveEpoch(epoch);
|
|
303
|
+
return epoch;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
async #finishEmptyApply(handle) {
|
|
307
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
308
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
309
|
+
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
310
|
+
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
311
|
+
epoch.lifecycle = "applied";
|
|
312
|
+
epoch.apply.phase = "control_published";
|
|
313
|
+
epoch.apply.committedHead = epoch.baseHead;
|
|
314
|
+
epoch.finishedAt = Date.now();
|
|
315
|
+
epoch.updatedAt = epoch.finishedAt;
|
|
316
|
+
await this.#store.saveEpoch(epoch);
|
|
317
|
+
return epoch;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async #recordStateCommit(handle, outcome) {
|
|
321
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
322
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
323
|
+
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
324
|
+
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
325
|
+
epoch.apply.phase = "state_committed";
|
|
326
|
+
epoch.apply.committedHead = outcome.head;
|
|
327
|
+
epoch.updatedAt = Date.now();
|
|
328
|
+
await this.#store.saveEpoch(epoch);
|
|
329
|
+
return epoch;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async #recordControlPublication(handle) {
|
|
333
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
334
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
335
|
+
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
336
|
+
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
337
|
+
epoch.apply.phase = "control_published";
|
|
338
|
+
epoch.updatedAt = Date.now();
|
|
339
|
+
await this.#store.saveEpoch(epoch);
|
|
340
|
+
return epoch;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async #finishApply(handle) {
|
|
344
|
+
return this.#withRevisionOwnership(handle, async () => {
|
|
345
|
+
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
346
|
+
if (epoch.lifecycle !== "applying" ||
|
|
347
|
+
epoch.apply?.phase !== "control_published")
|
|
348
|
+
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
349
|
+
epoch.lifecycle = "applied";
|
|
350
|
+
epoch.finishedAt = Date.now();
|
|
351
|
+
epoch.updatedAt = epoch.finishedAt;
|
|
352
|
+
await this.#store.saveEpoch(epoch);
|
|
353
|
+
return epoch;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
async #withRevisionOwnership(handle, action) {
|
|
357
|
+
try {
|
|
358
|
+
return await this.#worlds.operations.withWorldRevisionLock(handle, action);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error instanceof WorldOperationClaimLostError)
|
|
362
|
+
throw new WorldRevisionWorkspaceError("The world-revision epoch no longer owns this world's lock", { cause: error });
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function revisionHandle(input) {
|
|
368
|
+
return { worldId: input.worldId, epochId: input.epochId };
|
|
369
|
+
}
|
|
370
|
+
function assertExpectedRevision(epoch, expected) {
|
|
371
|
+
if (epoch.revision !== expected)
|
|
372
|
+
throw new WorldRevisionWorkspaceError("The world-revision worktree changed; refresh it before writing");
|
|
373
|
+
}
|
|
374
|
+
function stateChanges(baseFiles, files) {
|
|
375
|
+
const base = new Map(baseFiles
|
|
376
|
+
.filter(({ path }) => path.startsWith("state/"))
|
|
377
|
+
.map((file) => [file.path, file]));
|
|
378
|
+
const snapshot = WorldDocumentStore.open({
|
|
379
|
+
layout: "world_state",
|
|
380
|
+
files: files.filter(({ path }) => path.startsWith("state/")),
|
|
381
|
+
});
|
|
382
|
+
if (snapshot.status !== "usable")
|
|
383
|
+
throw new WorldRevisionWorkspaceError("Only a usable world-state snapshot can be applied");
|
|
384
|
+
return files
|
|
385
|
+
.filter(({ path }) => path.startsWith("state/"))
|
|
386
|
+
.flatMap((file) => {
|
|
387
|
+
const previous = base.get(file.path);
|
|
388
|
+
if (previous?.contents === file.contents)
|
|
389
|
+
return [];
|
|
390
|
+
const result = snapshot.query({
|
|
391
|
+
kind: "read_document",
|
|
392
|
+
document: { logicalPath: file.path },
|
|
393
|
+
});
|
|
394
|
+
if (result.kind !== "read_document" || !result.ok)
|
|
395
|
+
throw new WorldRevisionWorkspaceError(`Cannot resolve changed world document: ${file.path}`);
|
|
396
|
+
if (previous !== undefined) {
|
|
397
|
+
const baseSnapshot = WorldDocumentStore.open({
|
|
398
|
+
layout: "world_state",
|
|
399
|
+
files: baseFiles.filter(({ path }) => path.startsWith("state/")),
|
|
400
|
+
});
|
|
401
|
+
const before = baseSnapshot.query({
|
|
402
|
+
kind: "read_document",
|
|
403
|
+
document: { logicalPath: file.path },
|
|
404
|
+
});
|
|
405
|
+
if (before.kind !== "read_document" ||
|
|
406
|
+
!before.ok ||
|
|
407
|
+
before.document.documentId !== result.document.documentId)
|
|
408
|
+
throw new WorldRevisionWorkspaceError(`Existing world-document identity changed: ${file.path}`);
|
|
409
|
+
}
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
kind: previous === undefined ? "create" : "replace",
|
|
413
|
+
documentId: result.document.documentId,
|
|
414
|
+
stableShortRef: result.document.shortRef,
|
|
415
|
+
relativePath: file.path.slice("state/".length),
|
|
416
|
+
codec: result.document.codec,
|
|
417
|
+
expectedPreviousHash: previous === undefined ? null : sha256(previous.contents),
|
|
418
|
+
nextHash: sha256(file.contents),
|
|
419
|
+
canonicalNextBytes: file.contents,
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
function controlFiles(files) {
|
|
425
|
+
return files
|
|
426
|
+
.filter(({ path }) => path.startsWith("control/"))
|
|
427
|
+
.map(({ path, contents, encoding }) => ({
|
|
428
|
+
path: path.slice("control/".length),
|
|
429
|
+
contents,
|
|
430
|
+
...(encoding === undefined ? {} : { encoding }),
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
function controlFingerprint(files) {
|
|
434
|
+
const hash = createHash("sha256");
|
|
435
|
+
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path)))
|
|
436
|
+
hash.update(file.path).update("\0").update(file.contents).update("\0");
|
|
437
|
+
return `sha256:${hash.digest("hex")}`;
|
|
438
|
+
}
|
|
439
|
+
function sha256(contents) {
|
|
440
|
+
return `sha256:${createHash("sha256").update(contents).digest("hex")}`;
|
|
441
|
+
}
|
|
442
|
+
function fileDiff(beforeFiles, afterFiles) {
|
|
443
|
+
const before = new Map(beforeFiles.map((file) => [file.path, file]));
|
|
444
|
+
const after = new Map(afterFiles.map((file) => [file.path, file]));
|
|
445
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
446
|
+
.sort()
|
|
447
|
+
.flatMap((path) => {
|
|
448
|
+
const left = before.get(path);
|
|
449
|
+
const right = after.get(path);
|
|
450
|
+
if (left?.contents === right?.contents &&
|
|
451
|
+
left?.encoding === right?.encoding)
|
|
452
|
+
return [];
|
|
453
|
+
return [
|
|
454
|
+
{
|
|
455
|
+
path,
|
|
456
|
+
kind: left === undefined
|
|
457
|
+
? "create"
|
|
458
|
+
: right === undefined
|
|
459
|
+
? "delete"
|
|
460
|
+
: "modify",
|
|
461
|
+
before: left?.contents ?? null,
|
|
462
|
+
after: right?.contents ?? null,
|
|
463
|
+
},
|
|
464
|
+
];
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function fileMatchesBefore(files, change) {
|
|
468
|
+
const current = files.find(({ path }) => path === change.path);
|
|
469
|
+
return change.before === null
|
|
470
|
+
? current === undefined
|
|
471
|
+
: current?.contents === change.before && current.encoding === undefined;
|
|
472
|
+
}
|
|
473
|
+
function restoreBefore(files, change) {
|
|
474
|
+
const restored = new Map(files.map((file) => [file.path, structuredClone(file)]));
|
|
475
|
+
if (change.before === null)
|
|
476
|
+
restored.delete(change.path);
|
|
477
|
+
else
|
|
478
|
+
restored.set(change.path, { path: change.path, contents: change.before });
|
|
479
|
+
return [...restored.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
480
|
+
}
|
|
481
|
+
function inverseDiff(files, change) {
|
|
482
|
+
const before = files.find(({ path }) => path === change.path)?.contents ?? null;
|
|
483
|
+
return {
|
|
484
|
+
path: change.path,
|
|
485
|
+
kind: before === null ? "create" : change.before === null ? "delete" : "modify",
|
|
486
|
+
before,
|
|
487
|
+
after: change.before,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
function cloneFiles(files) {
|
|
491
|
+
return files
|
|
492
|
+
.map((file) => structuredClone(file))
|
|
493
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
494
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { conversationUpdate, } from "../protocol/conversationObservation.js";
|
|
4
|
+
import { v1Protocol } from "../protocol/v1.js";
|
|
5
|
+
/** A connection observes an independently owned Runtime task. No connection
|
|
6
|
+
* teardown path has access to the model's cancellation signal. */
|
|
7
|
+
export function registerConversationStream(server, runtime) {
|
|
8
|
+
const connections = new Set();
|
|
9
|
+
server.addHook("preClose", () => {
|
|
10
|
+
for (const stream of connections)
|
|
11
|
+
stream.destroy();
|
|
12
|
+
return Promise.resolve();
|
|
13
|
+
});
|
|
14
|
+
server.get("/api/runtime/v1/events", (request, reply) => {
|
|
15
|
+
const { kind, id, sessionId } = request.query;
|
|
16
|
+
if ((kind !== "play" && kind !== "setting" && kind !== "revision") ||
|
|
17
|
+
typeof id !== "string" ||
|
|
18
|
+
!/^[A-Za-z0-9_-]{1,200}$/.test(id) ||
|
|
19
|
+
(sessionId !== undefined &&
|
|
20
|
+
(kind === "play" ||
|
|
21
|
+
typeof sessionId !== "string" ||
|
|
22
|
+
!/^[A-Za-z0-9_-]{1,200}$/.test(sessionId)))) {
|
|
23
|
+
return reply.code(400).send({ error: "Invalid conversation target" });
|
|
24
|
+
}
|
|
25
|
+
const target = kind === "play"
|
|
26
|
+
? { kind, id }
|
|
27
|
+
: { kind, id, ...(sessionId === undefined ? {} : { sessionId }) };
|
|
28
|
+
const stream = new PassThrough();
|
|
29
|
+
connections.add(stream);
|
|
30
|
+
const epoch = randomUUID();
|
|
31
|
+
let sequence = 0;
|
|
32
|
+
let changes = 0;
|
|
33
|
+
let durableChanged = true;
|
|
34
|
+
let reading = false;
|
|
35
|
+
let previous = null;
|
|
36
|
+
let timer;
|
|
37
|
+
const write = (text) => {
|
|
38
|
+
if (stream.destroyed)
|
|
39
|
+
return;
|
|
40
|
+
// A slow renderer may reconnect from a snapshot; it cannot backpressure
|
|
41
|
+
// Provider execution or accumulate unbounded queued token events.
|
|
42
|
+
if (stream.readableLength + stream.writableLength > 1024 * 1024) {
|
|
43
|
+
stream.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
stream.write(text);
|
|
47
|
+
};
|
|
48
|
+
const read = async () => {
|
|
49
|
+
if (reading || stream.destroyed)
|
|
50
|
+
return;
|
|
51
|
+
reading = true;
|
|
52
|
+
const revision = changes;
|
|
53
|
+
const reload = durableChanged;
|
|
54
|
+
durableChanged = false;
|
|
55
|
+
try {
|
|
56
|
+
const next = await runtime.readConversation(target, reload ? undefined : (previous ?? undefined));
|
|
57
|
+
if (stream.destroyed)
|
|
58
|
+
return;
|
|
59
|
+
// Subscribe-before-read plus a revision check closes the initialization
|
|
60
|
+
// and asynchronous snapshot race without replaying transient deltas.
|
|
61
|
+
if (revision !== changes)
|
|
62
|
+
schedule();
|
|
63
|
+
if (previous === null ||
|
|
64
|
+
JSON.stringify(previous) !== JSON.stringify(next)) {
|
|
65
|
+
const update = conversationUpdate(previous, next);
|
|
66
|
+
write(`id: ${epoch}:${++sequence}\nevent: observation\ndata: ${JSON.stringify({ protocol: v1Protocol, update })}\n\n`);
|
|
67
|
+
previous = next;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
write(`event: failure\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : "Conversation observation failed" })}\n\n`);
|
|
72
|
+
stream.end();
|
|
73
|
+
cleanup();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
reading = false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const schedule = () => {
|
|
80
|
+
if (timer !== undefined || stream.destroyed)
|
|
81
|
+
return;
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
timer = undefined;
|
|
84
|
+
void read();
|
|
85
|
+
}, 25);
|
|
86
|
+
};
|
|
87
|
+
const unsubscribe = runtime.subscribeConversation(target, (durable) => {
|
|
88
|
+
changes += 1;
|
|
89
|
+
durableChanged ||= durable;
|
|
90
|
+
schedule();
|
|
91
|
+
});
|
|
92
|
+
const heartbeat = setInterval(() => write(": heartbeat\n\n"), 15_000);
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
unsubscribe();
|
|
95
|
+
clearInterval(heartbeat);
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
connections.delete(stream);
|
|
98
|
+
};
|
|
99
|
+
stream.on("close", cleanup);
|
|
100
|
+
reply.raw.on("close", () => stream.destroy());
|
|
101
|
+
write(": connected\nretry: 1000\n\n");
|
|
102
|
+
void read();
|
|
103
|
+
return reply
|
|
104
|
+
.header("Cache-Control", "no-store")
|
|
105
|
+
.header("X-Accel-Buffering", "no")
|
|
106
|
+
.type("text/event-stream; charset=utf-8")
|
|
107
|
+
.send(stream);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerConversationStream } from "./ConversationStream.js";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { PassThrough } from "node:stream";
|
|
@@ -27,7 +28,9 @@ function isPollingRequest(request) {
|
|
|
27
28
|
return false;
|
|
28
29
|
const runtimeRequest = body.request;
|
|
29
30
|
return (isRecord(runtimeRequest) &&
|
|
30
|
-
(runtimeRequest.type === "setting-improvement.
|
|
31
|
+
(runtimeRequest.type === "setting-improvement.read" ||
|
|
32
|
+
runtimeRequest.type === "setting-improvement.status" ||
|
|
33
|
+
runtimeRequest.type === "setting-improvement.overview" ||
|
|
31
34
|
runtimeRequest.type === "play.chain.inspect"));
|
|
32
35
|
}
|
|
33
36
|
function isRuntimeApiRequest(request) {
|
|
@@ -77,6 +80,7 @@ export async function createServer(input) {
|
|
|
77
80
|
.header("X-Frame-Options", "DENY");
|
|
78
81
|
done(null, payload);
|
|
79
82
|
});
|
|
83
|
+
registerConversationStream(server, input.runtime);
|
|
80
84
|
server.get("/health", () => ({
|
|
81
85
|
status: "ok",
|
|
82
86
|
protocol: "narraeon.runtime/v1",
|