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.
@@ -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.#worlds.deriveWorld({
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
- if (this.#connection.provider === "chat_completions")
86
- return this.#chat(request, observer?.onDelta);
87
- if (this.#connection.provider === "openai_responses")
88
- return this.#responses(request, observer?.onDelta);
89
- return this.#anthropic(request, observer?.onDelta);
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 = this.#contextRoot(value.worldId, value.chainId);
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(value.worldId, value.lastRequest);
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(this.#timelineHeadPath(value.worldId), {
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(worldId, request) {
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(this.#worldRuntimeRoot(worldId), "play-requests", `${digest}.json`), request);
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" || typeof value.message !== "string")
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" || value.kind === "failure") {
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
  }