narraeon 0.2.0 → 0.3.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.
- package/dist/node/protocol/v1.js +6 -0
- package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
- package/dist/node/runtime/V1Runtime.js +7 -6
- package/dist/node/runtime/model/FileNativeModelAdapters.js +40 -13
- package/dist/node/runtime/model/ModelHost.js +7 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +96 -7
- package/dist/node/runtime/play/PlayCallChain.js +309 -53
- package/dist/node/runtime/play/PlayFollowupRequests.js +42 -22
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +5 -5
- package/dist/node/runtime/world/FileNativeAuthorityV3.js +971 -0
- package/dist/node/runtime/world/FileNativeHistoryProjection.js +39 -0
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +454 -6
- package/dist/node/runtime/world/FileNativeWorldStore.js +423 -850
- package/dist/node/server/createServer.js +10 -2
- package/dist/web/assets/index-D5VqDvjY.js +1192 -0
- package/dist/web/assets/index-Xa0s35CJ.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DHV_VWl3.js +0 -1192
- package/dist/web/assets/index-DjLCkP99.css +0 -1
package/dist/node/protocol/v1.js
CHANGED
|
@@ -106,6 +106,11 @@ const requiredFields = {
|
|
|
106
106
|
exchangeId: "string",
|
|
107
107
|
playerText: "string",
|
|
108
108
|
},
|
|
109
|
+
"play.chain.cancel": {
|
|
110
|
+
worldId: "string",
|
|
111
|
+
chainId: "string",
|
|
112
|
+
exchangeId: "string",
|
|
113
|
+
},
|
|
109
114
|
"play.chain.inspect": { worldId: "string" },
|
|
110
115
|
"play.timeline.page": { worldId: "string", limit: "number" },
|
|
111
116
|
"play.timeline.detail": {
|
|
@@ -289,6 +294,7 @@ const requestTypes = new Set([
|
|
|
289
294
|
"play.chain.revise-player",
|
|
290
295
|
"play.chain.start",
|
|
291
296
|
"play.chain.append",
|
|
297
|
+
"play.chain.cancel",
|
|
292
298
|
"play.chain.inspect",
|
|
293
299
|
"play.timeline.page",
|
|
294
300
|
"play.timeline.detail",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { copyFile, link, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const hardlinkFallbackCodes = new Set([
|
|
5
|
+
"EXDEV",
|
|
6
|
+
"EPERM",
|
|
7
|
+
"EACCES",
|
|
8
|
+
"EMLINK",
|
|
9
|
+
"ENOTSUP",
|
|
10
|
+
"EOPNOTSUPP",
|
|
11
|
+
"ENOSYS",
|
|
12
|
+
]);
|
|
13
|
+
const reflinkFallbackCodes = new Set([
|
|
14
|
+
"EXDEV",
|
|
15
|
+
"EPERM",
|
|
16
|
+
"EACCES",
|
|
17
|
+
"ENOTSUP",
|
|
18
|
+
"EOPNOTSUPP",
|
|
19
|
+
"EINVAL",
|
|
20
|
+
"ENOSYS",
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Retains one file without aliasing mutable bytes. Immutable files prefer a
|
|
24
|
+
* hard link; every other case walks reflink -> exclusive byte copy.
|
|
25
|
+
*/
|
|
26
|
+
export async function cloneFilePhysically(input) {
|
|
27
|
+
await mkdir(dirname(input.target), { recursive: true, mode: 0o700 });
|
|
28
|
+
const strategy = process.env.NARRAEON_INTERNAL_TEST_CLONE_STRATEGY;
|
|
29
|
+
if (input.immutable && strategy !== "reflink" && strategy !== "copy") {
|
|
30
|
+
try {
|
|
31
|
+
await link(input.source, input.target);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (await acceptExistingTarget(error, input))
|
|
36
|
+
return;
|
|
37
|
+
if (!isFallbackError(error, hardlinkFallbackCodes))
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (strategy !== "copy") {
|
|
42
|
+
try {
|
|
43
|
+
await copyFileAsReflink(input.source, input.target);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (await acceptExistingTarget(error, input))
|
|
48
|
+
return;
|
|
49
|
+
if (!isFallbackError(error, reflinkFallbackCodes))
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await copyFile(input.source, input.target, constants.COPYFILE_EXCL);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (await acceptExistingTarget(error, input))
|
|
58
|
+
return;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function copyFileAsReflink(source, target) {
|
|
63
|
+
const injectedErrorCode = process.env.NARRAEON_INTERNAL_TEST_REFLINK_ERROR_CODE;
|
|
64
|
+
if (injectedErrorCode !== undefined) {
|
|
65
|
+
const error = new Error(`${injectedErrorCode}: injected copy-on-write clone failure`);
|
|
66
|
+
error.code = injectedErrorCode;
|
|
67
|
+
error.path = source;
|
|
68
|
+
error.syscall = "copyfile";
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
await copyFile(source, target, constants.COPYFILE_FICLONE_FORCE);
|
|
72
|
+
}
|
|
73
|
+
async function acceptExistingTarget(error, input) {
|
|
74
|
+
if (!isNodeError(error) ||
|
|
75
|
+
error.code !== "EEXIST" ||
|
|
76
|
+
input.onTargetExists === undefined)
|
|
77
|
+
return false;
|
|
78
|
+
await input.onTargetExists(input.source, input.target);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
function isFallbackError(error, acceptedCodes) {
|
|
82
|
+
return isNodeError(error) && acceptedCodes.has(error.code ?? "");
|
|
83
|
+
}
|
|
84
|
+
function isNodeError(error) {
|
|
85
|
+
return error instanceof Error && "code" in error;
|
|
86
|
+
}
|
|
@@ -374,17 +374,12 @@ export class V1Runtime {
|
|
|
374
374
|
}
|
|
375
375
|
case "world.derive": {
|
|
376
376
|
const preset = await this.#playPresets.bindCurrent();
|
|
377
|
-
const result = await this.#
|
|
377
|
+
const result = await this.#playCallChains.deriveWorld({
|
|
378
378
|
operationId: request.operationId,
|
|
379
379
|
sourceWorldId: request.sourceWorldId,
|
|
380
380
|
sourceHead: request.sourceHead,
|
|
381
381
|
hostPresetId: preset.id,
|
|
382
382
|
});
|
|
383
|
-
await this.#playCallChains.forkToDerivedWorld({
|
|
384
|
-
sourceWorldId: request.sourceWorldId,
|
|
385
|
-
sourceHead: request.sourceHead,
|
|
386
|
-
targetWorldId: result.world.worldId,
|
|
387
|
-
});
|
|
388
383
|
await this.#reconcileResultArtifacts(result);
|
|
389
384
|
return result;
|
|
390
385
|
}
|
|
@@ -427,6 +422,12 @@ export class V1Runtime {
|
|
|
427
422
|
? {}
|
|
428
423
|
: { observer: playCallChainObserver }),
|
|
429
424
|
}));
|
|
425
|
+
case "play.chain.cancel":
|
|
426
|
+
return playCall(() => this.#playCallChains.cancel({
|
|
427
|
+
worldId: request.worldId,
|
|
428
|
+
chainId: request.chainId,
|
|
429
|
+
exchangeId: request.exchangeId,
|
|
430
|
+
}));
|
|
430
431
|
case "play.chain.inspect":
|
|
431
432
|
return this.#playCallChains.inspectWorld(request.worldId);
|
|
432
433
|
case "play.timeline.page":
|
|
@@ -3,7 +3,7 @@ import { appendFile, chmod, mkdir, stat } from "node:fs/promises";
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { defaultAppLocale, } from "../../protocol/appPreferences.js";
|
|
5
5
|
import { hasCLIProxyThinkingSuffix, modelReasoningPolicyIssue, } from "../../protocol/modelConnections.js";
|
|
6
|
-
import { ModelHostBindingMismatchError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
|
|
6
|
+
import { ModelHostBindingMismatchError, ModelHostCancelledError, ModelHostContinuationError, ModelHostFailureError, ModelHostOutcomeUnknownError, } from "./ModelHost.js";
|
|
7
7
|
import { renderPromptDeltaMessage, } from "../prompt/FileNativePromptCompiler.js";
|
|
8
8
|
import { aggregateAnthropicSettingStream, aggregateChatSettingStream, } from "./SettingAuthorStream.js";
|
|
9
9
|
import { aggregateAnthropicModelStream, aggregateChatModelStream, aggregateResponsesModelStream, } from "./ModelHostStream.js";
|
|
@@ -82,11 +82,22 @@ export class FileNativeModelHost {
|
|
|
82
82
|
async exchange(request, observer) {
|
|
83
83
|
assertExchangeCompatibility(this.#connection, request);
|
|
84
84
|
await trace("model_host_exchange", modelHostTrace(request));
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
const signal = observer?.signal;
|
|
86
|
+
if (signalWasAborted(signal))
|
|
87
|
+
throw cancelledModelRequest();
|
|
88
|
+
try {
|
|
89
|
+
if (this.#connection.provider === "chat_completions")
|
|
90
|
+
return await this.#chat(request, observer?.onDelta, signal);
|
|
91
|
+
if (this.#connection.provider === "openai_responses")
|
|
92
|
+
return await this.#responses(request, observer?.onDelta, signal);
|
|
93
|
+
return await this.#anthropic(request, observer?.onDelta, signal);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (signalWasAborted(signal) &&
|
|
97
|
+
!(error instanceof ModelHostCancelledError))
|
|
98
|
+
throw cancelledModelRequest(error);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
90
101
|
}
|
|
91
102
|
/** Uses the production encoders and deliberately omits credentials. */
|
|
92
103
|
previewRequest(request) {
|
|
@@ -107,7 +118,7 @@ export class FileNativeModelHost {
|
|
|
107
118
|
body: cloneProviderValue(body),
|
|
108
119
|
};
|
|
109
120
|
}
|
|
110
|
-
async #responses(request, onDelta) {
|
|
121
|
+
async #responses(request, onDelta, signal) {
|
|
111
122
|
return openAIResponsesRequest({
|
|
112
123
|
...responsesRequestInput(this.#connection, request),
|
|
113
124
|
fetchImplementation: this.#fetch,
|
|
@@ -117,9 +128,10 @@ export class FileNativeModelHost {
|
|
|
117
128
|
: { failureLog: this.#failureLog }),
|
|
118
129
|
diagnosticContext: modelDiagnosticContext(request),
|
|
119
130
|
...(onDelta === undefined ? {} : { onDelta }),
|
|
131
|
+
...(signal === undefined ? {} : { signal }),
|
|
120
132
|
});
|
|
121
133
|
}
|
|
122
|
-
async #chat(request, onDelta) {
|
|
134
|
+
async #chat(request, onDelta, signal) {
|
|
123
135
|
const body = chatRequestBody(this.#connection, request);
|
|
124
136
|
const bodyJson = JSON.stringify(body);
|
|
125
137
|
await trace("chat_request", body);
|
|
@@ -135,11 +147,12 @@ export class FileNativeModelHost {
|
|
|
135
147
|
"Content-Type": "application/json",
|
|
136
148
|
},
|
|
137
149
|
body: bodyJson,
|
|
150
|
+
...(signal === undefined ? {} : { signal }),
|
|
138
151
|
}, "Chat Completions"));
|
|
139
152
|
if (!response.ok)
|
|
140
153
|
throw await providerError(response);
|
|
141
154
|
if (isProviderEventStream(response)) {
|
|
142
|
-
const streamed = await providerStreamResult(response, "Chat Completions", (body) => aggregateChatModelStream(body, diagnosticDelta));
|
|
155
|
+
const streamed = await providerStreamResult(response, "Chat Completions", (body) => aggregateChatModelStream(body, diagnosticDelta), signal);
|
|
143
156
|
capture?.setReasoning(streamed.reasoningContent);
|
|
144
157
|
await trace("chat_response", streamed);
|
|
145
158
|
return {
|
|
@@ -202,7 +215,7 @@ export class FileNativeModelHost {
|
|
|
202
215
|
};
|
|
203
216
|
});
|
|
204
217
|
}
|
|
205
|
-
async #anthropic(request, onDelta) {
|
|
218
|
+
async #anthropic(request, onDelta, signal) {
|
|
206
219
|
const body = anthropicRequestBody(this.#connection, request);
|
|
207
220
|
const bodyJson = JSON.stringify(body);
|
|
208
221
|
await trace("anthropic_request", body);
|
|
@@ -219,11 +232,12 @@ export class FileNativeModelHost {
|
|
|
219
232
|
"Content-Type": "application/json",
|
|
220
233
|
},
|
|
221
234
|
body: bodyJson,
|
|
235
|
+
...(signal === undefined ? {} : { signal }),
|
|
222
236
|
}, "Anthropic Messages"));
|
|
223
237
|
if (!response.ok)
|
|
224
238
|
throw await providerError(response);
|
|
225
239
|
if (isProviderEventStream(response)) {
|
|
226
|
-
const streamed = await providerStreamResult(response, "Anthropic Messages", (body) => aggregateAnthropicModelStream(body, diagnosticDelta));
|
|
240
|
+
const streamed = await providerStreamResult(response, "Anthropic Messages", (body) => aggregateAnthropicModelStream(body, diagnosticDelta), signal);
|
|
227
241
|
capture?.setReasoning(streamed.reasoningContent);
|
|
228
242
|
await trace("anthropic_response", streamed);
|
|
229
243
|
return {
|
|
@@ -380,6 +394,8 @@ async function capturedProviderOperation(failureLog, capture, operation) {
|
|
|
380
394
|
return { ...value, diagnostics: capture.snapshot() };
|
|
381
395
|
}
|
|
382
396
|
catch (error) {
|
|
397
|
+
if (error instanceof ModelHostCancelledError)
|
|
398
|
+
throw error;
|
|
383
399
|
if (capture !== undefined && failureLog !== undefined) {
|
|
384
400
|
const exchange = capture.snapshot();
|
|
385
401
|
const failure = {
|
|
@@ -1383,11 +1399,12 @@ async function openAIResponsesRequest(input) {
|
|
|
1383
1399
|
"Content-Type": "application/json",
|
|
1384
1400
|
},
|
|
1385
1401
|
body: bodyJson,
|
|
1402
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
1386
1403
|
}, "OpenAI Responses"));
|
|
1387
1404
|
if (!response.ok)
|
|
1388
1405
|
throw await providerError(response);
|
|
1389
1406
|
const payload = isProviderEventStream(response)
|
|
1390
|
-
? await providerStreamResult(response, "Responses API", (body) => aggregateResponsesModelStream(body, diagnosticDelta))
|
|
1407
|
+
? await providerStreamResult(response, "Responses API", (body) => aggregateResponsesModelStream(body, diagnosticDelta), input.signal)
|
|
1391
1408
|
: await providerJson(response, "Responses API");
|
|
1392
1409
|
await trace(`${input.tracePrefix}_response`, payload);
|
|
1393
1410
|
if (!isRecord(payload) || !Array.isArray(payload.output))
|
|
@@ -1626,13 +1643,15 @@ function isProviderEventStream(response) {
|
|
|
1626
1643
|
?.toLowerCase()
|
|
1627
1644
|
.includes("text/event-stream") === true);
|
|
1628
1645
|
}
|
|
1629
|
-
async function providerStreamResult(response, label, read) {
|
|
1646
|
+
async function providerStreamResult(response, label, read, signal) {
|
|
1630
1647
|
if (response.body === null)
|
|
1631
1648
|
throw new ModelHostOutcomeUnknownError(`${label} was dispatched but its streaming response has no body`);
|
|
1632
1649
|
try {
|
|
1633
1650
|
return await read(response.body);
|
|
1634
1651
|
}
|
|
1635
1652
|
catch (error) {
|
|
1653
|
+
if (signal?.aborted === true)
|
|
1654
|
+
throw cancelledModelRequest(error);
|
|
1636
1655
|
if (error instanceof ModelHostFailureError ||
|
|
1637
1656
|
error instanceof ModelHostOutcomeUnknownError)
|
|
1638
1657
|
throw error;
|
|
@@ -1644,11 +1663,19 @@ async function dispatchProviderRequest(fetchImplementation, url, init, label) {
|
|
|
1644
1663
|
return await fetchImplementation(url, init);
|
|
1645
1664
|
}
|
|
1646
1665
|
catch (error) {
|
|
1666
|
+
if (init.signal?.aborted === true)
|
|
1667
|
+
throw cancelledModelRequest(error);
|
|
1647
1668
|
throw new ModelHostOutcomeUnknownError(`${label} request was dispatched but its outcome is unknown`, {
|
|
1648
1669
|
cause: error,
|
|
1649
1670
|
});
|
|
1650
1671
|
}
|
|
1651
1672
|
}
|
|
1673
|
+
function cancelledModelRequest(cause) {
|
|
1674
|
+
return new ModelHostCancelledError("The player cancelled the dispatched model request; its partial response was not committed and cannot be replayed.", cause === undefined ? undefined : { cause });
|
|
1675
|
+
}
|
|
1676
|
+
function signalWasAborted(signal) {
|
|
1677
|
+
return signal?.aborted === true;
|
|
1678
|
+
}
|
|
1652
1679
|
function unknownProviderResponse(label) {
|
|
1653
1680
|
return new ModelHostOutcomeUnknownError(`${label} was dispatched but its response shape could not be confirmed`);
|
|
1654
1681
|
}
|
|
@@ -39,6 +39,13 @@ export class ModelHostOutcomeUnknownError extends Error {
|
|
|
39
39
|
this.name = "ModelHostOutcomeUnknownError";
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/** The player explicitly stopped a dispatched request; it remains non-replayable. */
|
|
43
|
+
export class ModelHostCancelledError extends ModelHostOutcomeUnknownError {
|
|
44
|
+
constructor(message, options) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
this.name = "ModelHostCancelledError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
42
49
|
/** Deterministic adapter for exercising the true external ModelHost port. */
|
|
43
50
|
/**
|
|
44
51
|
* Deterministic test double. Its synthetic provider state is fixture data, not
|
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { cloneFilePhysically } from "../FileNativePhysicalClone.js";
|
|
5
6
|
export class FileNativePlayTimelineStore {
|
|
6
7
|
#worldsRoot;
|
|
7
8
|
constructor(dataRoot) {
|
|
@@ -49,9 +50,69 @@ export class FileNativePlayTimelineStore {
|
|
|
49
50
|
throw new Error("Released play-call-chain migration did not reproduce its context chain");
|
|
50
51
|
}
|
|
51
52
|
async persist(value, cursor) {
|
|
53
|
+
return this.#persistAtRuntimeRoot(this.#worldRuntimeRoot(value.worldId), value, cursor);
|
|
54
|
+
}
|
|
55
|
+
async persistStaged(targetWorldRoot, value, cursor) {
|
|
56
|
+
return this.#persistAtRuntimeRoot(join(resolve(targetWorldRoot), "runtime"), value, cursor);
|
|
57
|
+
}
|
|
58
|
+
async cloneContextPrefixToStaging(input) {
|
|
59
|
+
assertIdentity(input.sourceWorldId, "Source world ID");
|
|
60
|
+
assertIdentity(input.source.chainId, "Source call-chain ID");
|
|
61
|
+
const sourceRoot = this.#contextRoot(input.sourceWorldId, input.source.chainId);
|
|
62
|
+
const targetRuntime = join(resolve(input.targetWorldRoot), "runtime");
|
|
63
|
+
const targetRoot = join(targetRuntime, "play-contexts", createHash("sha256").update(input.target.chainId).digest("hex"));
|
|
64
|
+
const transcriptCount = prefixLength(input.source.transcript, input.target.transcript);
|
|
65
|
+
const eventCount = prefixLength(input.source.events, input.target.events);
|
|
66
|
+
const completedToolCount = prefixLength(input.source.completedTools, input.target.completedTools);
|
|
67
|
+
const authorizationCheckpointCount = prefixLength(input.source.documentAuthorizationCheckpoints ?? [], input.target.documentAuthorizationCheckpoints ?? []);
|
|
68
|
+
await Promise.all([
|
|
69
|
+
cloneNumberedPrefix({
|
|
70
|
+
sourceRoot,
|
|
71
|
+
targetRoot,
|
|
72
|
+
directory: "transcript",
|
|
73
|
+
count: transcriptCount,
|
|
74
|
+
immutable: true,
|
|
75
|
+
}),
|
|
76
|
+
cloneNumberedPrefix({
|
|
77
|
+
sourceRoot,
|
|
78
|
+
targetRoot,
|
|
79
|
+
directory: "events",
|
|
80
|
+
count: eventCount,
|
|
81
|
+
immutable: false,
|
|
82
|
+
}),
|
|
83
|
+
cloneNumberedPrefix({
|
|
84
|
+
sourceRoot,
|
|
85
|
+
targetRoot,
|
|
86
|
+
directory: "summaries",
|
|
87
|
+
count: eventCount,
|
|
88
|
+
immutable: false,
|
|
89
|
+
}),
|
|
90
|
+
cloneNumberedPrefix({
|
|
91
|
+
sourceRoot,
|
|
92
|
+
targetRoot,
|
|
93
|
+
directory: "completed-tools",
|
|
94
|
+
count: completedToolCount,
|
|
95
|
+
immutable: true,
|
|
96
|
+
}),
|
|
97
|
+
cloneNumberedPrefix({
|
|
98
|
+
sourceRoot,
|
|
99
|
+
targetRoot,
|
|
100
|
+
directory: "authorization",
|
|
101
|
+
count: authorizationCheckpointCount,
|
|
102
|
+
immutable: true,
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
return this.persistStaged(input.targetWorldRoot, input.target, {
|
|
106
|
+
transcriptCount,
|
|
107
|
+
eventCount,
|
|
108
|
+
completedToolCount,
|
|
109
|
+
authorizationCheckpointCount,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async #persistAtRuntimeRoot(runtimeRoot, value, cursor) {
|
|
52
113
|
assertIdentity(value.worldId, "World ID");
|
|
53
114
|
assertIdentity(value.chainId, "Call-chain ID");
|
|
54
|
-
const root =
|
|
115
|
+
const root = join(runtimeRoot, "play-contexts", createHash("sha256").update(value.chainId).digest("hex"));
|
|
55
116
|
const index = contextIndex(value);
|
|
56
117
|
const existingIndex = await readOptionalJson(join(root, "index.json"));
|
|
57
118
|
if (existingIndex === null)
|
|
@@ -76,7 +137,7 @@ export class FileNativePlayTimelineStore {
|
|
|
76
137
|
};
|
|
77
138
|
const lastRequestDigest = value.lastRequest === null
|
|
78
139
|
? null
|
|
79
|
-
: await this.#publishRequest(
|
|
140
|
+
: await this.#publishRequest(runtimeRoot, value.lastRequest);
|
|
80
141
|
const continuation = {
|
|
81
142
|
schemaVersion: 3,
|
|
82
143
|
kind: "play_context_continuation",
|
|
@@ -122,7 +183,7 @@ export class FileNativePlayTimelineStore {
|
|
|
122
183
|
updatedAt: value.updatedAt,
|
|
123
184
|
};
|
|
124
185
|
await publishJson(join(root, "state.json"), state);
|
|
125
|
-
await publishJson(
|
|
186
|
+
await publishJson(join(runtimeRoot, "play-timeline-head.json"), {
|
|
126
187
|
schemaVersion: 3,
|
|
127
188
|
worldId: value.worldId,
|
|
128
189
|
chainId: value.chainId,
|
|
@@ -379,11 +440,11 @@ export class FileNativePlayTimelineStore {
|
|
|
379
440
|
assertIdentity(value.chainId, "Call-chain ID");
|
|
380
441
|
return value;
|
|
381
442
|
}
|
|
382
|
-
async #publishRequest(
|
|
443
|
+
async #publishRequest(runtimeRoot, request) {
|
|
383
444
|
const digest = createHash("sha256")
|
|
384
445
|
.update(JSON.stringify(request))
|
|
385
446
|
.digest("hex");
|
|
386
|
-
await publishImmutableJson(join(
|
|
447
|
+
await publishImmutableJson(join(runtimeRoot, "play-requests", `${digest}.json`), request);
|
|
387
448
|
return digest;
|
|
388
449
|
}
|
|
389
450
|
async #initialCursor(worldId, generation, chainId) {
|
|
@@ -799,7 +860,8 @@ function assertPlayEvent(value) {
|
|
|
799
860
|
throw new Error("Follow-up timeline event has an invalid shape");
|
|
800
861
|
return;
|
|
801
862
|
}
|
|
802
|
-
if (value.kind !== "failure"
|
|
863
|
+
if ((value.kind !== "failure" && value.kind !== "cancellation") ||
|
|
864
|
+
typeof value.message !== "string")
|
|
803
865
|
throw new Error("Play timeline event has an invalid kind");
|
|
804
866
|
}
|
|
805
867
|
function assertTimelineEventSummary(value) {
|
|
@@ -807,7 +869,9 @@ function assertTimelineEventSummary(value) {
|
|
|
807
869
|
!Number.isSafeInteger(value.id) ||
|
|
808
870
|
Number(value.id) < 1)
|
|
809
871
|
throw new Error("Play timeline summary has an invalid identity");
|
|
810
|
-
if (value.kind === "player" ||
|
|
872
|
+
if (value.kind === "player" ||
|
|
873
|
+
value.kind === "failure" ||
|
|
874
|
+
value.kind === "cancellation") {
|
|
811
875
|
assertPlayEvent(value);
|
|
812
876
|
return;
|
|
813
877
|
}
|
|
@@ -955,6 +1019,31 @@ function isRecord(value) {
|
|
|
955
1019
|
function isNodeError(value) {
|
|
956
1020
|
return value instanceof Error && "code" in value;
|
|
957
1021
|
}
|
|
1022
|
+
function prefixLength(source, target) {
|
|
1023
|
+
return isDeepStrictEqual(source.slice(0, target.length), target)
|
|
1024
|
+
? target.length
|
|
1025
|
+
: 0;
|
|
1026
|
+
}
|
|
1027
|
+
async function cloneNumberedPrefix(input) {
|
|
1028
|
+
for (let index = 1; index <= input.count; index += 1)
|
|
1029
|
+
await cloneTimelineFile(join(input.sourceRoot, input.directory, numbered(index)), join(input.targetRoot, input.directory, numbered(index)), input.immutable);
|
|
1030
|
+
}
|
|
1031
|
+
async function cloneTimelineFile(source, target, immutable) {
|
|
1032
|
+
await cloneFilePhysically({
|
|
1033
|
+
source,
|
|
1034
|
+
target,
|
|
1035
|
+
immutable,
|
|
1036
|
+
onTargetExists: assertSameFileBytes,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async function assertSameFileBytes(source, target) {
|
|
1040
|
+
const [sourceBytes, targetBytes] = await Promise.all([
|
|
1041
|
+
readFile(source),
|
|
1042
|
+
readFile(target),
|
|
1043
|
+
]);
|
|
1044
|
+
if (!sourceBytes.equals(targetBytes))
|
|
1045
|
+
throw new Error("Cloned play timeline prefix conflicts in target staging");
|
|
1046
|
+
}
|
|
958
1047
|
async function readNumbered(root, count) {
|
|
959
1048
|
return Promise.all(Array.from({ length: count }, (_, index) => readJson(join(root, numbered(index + 1)))));
|
|
960
1049
|
}
|