narraeon 0.1.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/LICENSE +201 -0
- package/README.md +164 -0
- package/README.zh-CN.md +164 -0
- package/dist/node/cli/NarraeonCli.js +163 -0
- package/dist/node/cli/main.js +38 -0
- package/dist/node/protocol/appPreferences.js +5 -0
- package/dist/node/protocol/contentTree.js +14 -0
- package/dist/node/protocol/modelConnections.js +110 -0
- package/dist/node/protocol/playerViews.js +1 -0
- package/dist/node/protocol/v1.js +278 -0
- package/dist/node/runtime/V1Runtime.js +625 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
- package/dist/node/runtime/config/appPaths.js +19 -0
- package/dist/node/runtime/content/ContentLimits.js +1 -0
- package/dist/node/runtime/content/ContentTreeFile.js +1 -0
- package/dist/node/runtime/content/ContentWorkspace.js +601 -0
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
- package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
- package/dist/node/runtime/model/AiFailureLog.js +332 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
- package/dist/node/runtime/model/ModelHost.js +89 -0
- package/dist/node/runtime/model/ModelHostStream.js +367 -0
- package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
- package/dist/node/runtime/model/ProviderStream.js +90 -0
- package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
- package/dist/node/runtime/play/PlayCallChain.js +1280 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
- package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
- package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
- package/dist/node/runtime/world/WorldStateRevision.js +75 -0
- package/dist/node/server/createRuntime.js +6 -0
- package/dist/node/server/createServer.js +218 -0
- package/dist/node/server/main.js +25 -0
- package/dist/node/server/startWebServer.js +28 -0
- package/dist/node/shared/default-play-prompts.js +85 -0
- package/dist/node/shared/default-preset-host.js +538 -0
- package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
- package/dist/node/shared/first-party-action-choices.js +164 -0
- package/dist/node/shared/first-party-generic-panels.js +105 -0
- package/dist/node/shared/first-party-play-preset-templates.js +41 -0
- package/dist/node/shared/first-party-player-view.js +179 -0
- package/dist/node/shared/play-preset-prompt-roles.js +4 -0
- package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
- package/dist/web/assets/index-DDx1itzT.js +1192 -0
- package/dist/web/index.html +14 -0
- package/package.json +86 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { parsePlayPresetRegexAsset, } from "../play/FileNativePlayPresetStore.js";
|
|
2
|
+
export function projectArtifactForFrontend(artifact, binding, failure = "missing_revision") {
|
|
3
|
+
const preset = {
|
|
4
|
+
id: artifact.playPresetId,
|
|
5
|
+
revision: artifact.playPresetRevision,
|
|
6
|
+
};
|
|
7
|
+
if (binding === null)
|
|
8
|
+
return missingBundle(preset, "The frozen play-preset revision is unavailable and cannot be replaced with the current revision", failure);
|
|
9
|
+
if (binding.id !== artifact.playPresetId ||
|
|
10
|
+
binding.revision !== artifact.playPresetRevision)
|
|
11
|
+
return missingBundle(preset, "The play-preset binding does not match the artifact's frozen identity", "invalid_revision");
|
|
12
|
+
const followup = binding.definition.followups.find(({ id }) => id === artifact.requestId);
|
|
13
|
+
const declaration = followup?.artifacts.find(({ name }) => name === artifact.output);
|
|
14
|
+
if (declaration === undefined)
|
|
15
|
+
return {
|
|
16
|
+
...missingBundle(preset, "The artifact declaration is missing from the frozen revision"),
|
|
17
|
+
status: "missing_declaration",
|
|
18
|
+
};
|
|
19
|
+
if (!artifactMatchesDeclaration(artifact, declaration))
|
|
20
|
+
return {
|
|
21
|
+
...missingBundle(preset, "The raw artifact contract does not match the frozen declaration", "invalid_revision"),
|
|
22
|
+
status: "invalid_revision",
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
const frozenBinding = {
|
|
26
|
+
...binding,
|
|
27
|
+
scriptsEnabled: artifact.playPresetScriptsEnabled,
|
|
28
|
+
};
|
|
29
|
+
const regex = declaration.regex
|
|
30
|
+
? parsePlayPresetRegexAsset(frozenBinding.files[declaration.regex] ??
|
|
31
|
+
(() => {
|
|
32
|
+
throw new Error("The frozen regex resource does not exist");
|
|
33
|
+
})(), declaration.regex)
|
|
34
|
+
: [];
|
|
35
|
+
const renderer = resolveRenderer(declaration, frozenBinding);
|
|
36
|
+
const mount = frozenBinding.definition.mounts.find(({ channel }) => channel === declaration.channel)?.mount;
|
|
37
|
+
return {
|
|
38
|
+
status: "ready",
|
|
39
|
+
preset,
|
|
40
|
+
source: "artifact",
|
|
41
|
+
authority: "non_authoritative_artifact",
|
|
42
|
+
lifecycle: "operation_frozen",
|
|
43
|
+
...(mount === undefined ? {} : { mount }),
|
|
44
|
+
declaration: declarationView(declaration),
|
|
45
|
+
regex,
|
|
46
|
+
...(renderer === undefined ? {} : { renderer }),
|
|
47
|
+
trustedLocalCode: artifact.playPresetScriptsEnabled,
|
|
48
|
+
fallback: "none",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {
|
|
53
|
+
...missingBundle(preset, "The frozen extension resource is invalid", "invalid_revision"),
|
|
54
|
+
status: "invalid_revision",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Project a current-preset player-view panel through the same frozen-resource
|
|
60
|
+
* bundle seam as follow-up artifacts. Its lifecycle is explicit: it follows the
|
|
61
|
+
* currently selected preset, while historical artifacts continue to use their
|
|
62
|
+
* own operation-frozen revision.
|
|
63
|
+
*/
|
|
64
|
+
export function projectPlayerViewPanelForFrontend(panel, binding, failure = "missing_revision") {
|
|
65
|
+
const preset = binding === null
|
|
66
|
+
? { id: "unknown", revision: "unknown" }
|
|
67
|
+
: { id: binding.id, revision: binding.revision };
|
|
68
|
+
if (binding === null)
|
|
69
|
+
return {
|
|
70
|
+
...missingBundle(preset, "The current play-preset revision is unavailable, and player-view panels cannot use another revision", failure),
|
|
71
|
+
source: "player_view",
|
|
72
|
+
lifecycle: "current_preset",
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const regex = panel.regex
|
|
76
|
+
? parsePlayPresetRegexAsset(binding.files[panel.regex] ??
|
|
77
|
+
(() => {
|
|
78
|
+
throw new Error("The frozen regex resource does not exist");
|
|
79
|
+
})(), panel.regex)
|
|
80
|
+
: [];
|
|
81
|
+
const renderer = resolvePlayerViewRenderer(panel, binding);
|
|
82
|
+
return {
|
|
83
|
+
status: "ready",
|
|
84
|
+
preset,
|
|
85
|
+
source: "player_view",
|
|
86
|
+
authority: "committed_player_view_projection",
|
|
87
|
+
lifecycle: "current_preset",
|
|
88
|
+
mount: panel.mount,
|
|
89
|
+
declaration: {
|
|
90
|
+
outputName: panel.id,
|
|
91
|
+
channel: panel.channel,
|
|
92
|
+
key: panel.key,
|
|
93
|
+
contentType: "application/json",
|
|
94
|
+
projection: "upsert",
|
|
95
|
+
save: "none",
|
|
96
|
+
invalidation: "never",
|
|
97
|
+
required: false,
|
|
98
|
+
maxEmits: 1,
|
|
99
|
+
rendererMode: panel.rendererMode,
|
|
100
|
+
},
|
|
101
|
+
regex,
|
|
102
|
+
...(renderer === undefined ? {} : { renderer }),
|
|
103
|
+
trustedLocalCode: renderer?.trustedLocalCode ?? false,
|
|
104
|
+
fallback: "none",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
...missingBundle(preset, error instanceof Error
|
|
110
|
+
? error.message
|
|
111
|
+
: "The frozen player-view panel resource is invalid", "invalid_revision"),
|
|
112
|
+
source: "player_view",
|
|
113
|
+
lifecycle: "current_preset",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function artifactMatchesDeclaration(artifact, declaration) {
|
|
118
|
+
return (artifact.output === declaration.name &&
|
|
119
|
+
artifact.channel === declaration.channel &&
|
|
120
|
+
(artifact.key ?? undefined) === (declaration.key ?? undefined) &&
|
|
121
|
+
artifact.contentType === declaration.contentType &&
|
|
122
|
+
artifact.projection === declaration.strategy &&
|
|
123
|
+
artifact.save === declaration.save &&
|
|
124
|
+
(artifact.renderer ?? undefined) === (declaration.renderer ?? undefined) &&
|
|
125
|
+
(artifact.rendererRevision ?? undefined) ===
|
|
126
|
+
(declaration.rendererRevision ?? undefined));
|
|
127
|
+
}
|
|
128
|
+
export function projectDebugArtifactForFrontend(artifact, binding, failure = "missing_revision") {
|
|
129
|
+
return projectArtifactForFrontend(artifact, binding, failure);
|
|
130
|
+
}
|
|
131
|
+
function declarationView(declaration) {
|
|
132
|
+
return {
|
|
133
|
+
outputName: declaration.name,
|
|
134
|
+
channel: declaration.channel,
|
|
135
|
+
...(declaration.key === undefined ? {} : { key: declaration.key }),
|
|
136
|
+
contentType: declaration.contentType,
|
|
137
|
+
projection: declaration.strategy,
|
|
138
|
+
save: declaration.save,
|
|
139
|
+
invalidation: declaration.invalidation,
|
|
140
|
+
required: declaration.required,
|
|
141
|
+
maxEmits: declaration.maxEmits,
|
|
142
|
+
...(declaration.payloadContract === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { payloadContract: structuredClone(declaration.payloadContract) }),
|
|
145
|
+
rendererMode: declaration.rendererMode ?? "document",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function resolveRenderer(declaration, binding) {
|
|
149
|
+
const mode = declaration.rendererMode ?? "document";
|
|
150
|
+
const rendererSource = declaration.renderer
|
|
151
|
+
? binding.files[declaration.renderer]
|
|
152
|
+
: undefined;
|
|
153
|
+
if (declaration.renderer !== undefined && rendererSource === undefined)
|
|
154
|
+
throw new Error("The frozen renderer resource does not exist");
|
|
155
|
+
const scriptSources = (declaration.scripts ?? []).map((path) => {
|
|
156
|
+
const source = binding.files[path];
|
|
157
|
+
if (source === undefined)
|
|
158
|
+
throw new Error("The frozen script resource does not exist");
|
|
159
|
+
return source;
|
|
160
|
+
});
|
|
161
|
+
const scripts = binding.scriptsEnabled ? scriptSources : [];
|
|
162
|
+
const assets = (declaration.assets ?? []).map((path) => {
|
|
163
|
+
const source = binding.files[path];
|
|
164
|
+
if (source === undefined)
|
|
165
|
+
throw new Error("The frozen asset resource does not exist");
|
|
166
|
+
// This is a frozen, author-visible logical asset id, not a machine path.
|
|
167
|
+
// Keeping the preset-relative declaration key stable lets app renderers
|
|
168
|
+
// refer to `window.__NARRAEON_ASSETS__[id]` across exchanges/revisions.
|
|
169
|
+
return { id: path, source };
|
|
170
|
+
});
|
|
171
|
+
if (rendererSource === undefined &&
|
|
172
|
+
scripts.length === 0 &&
|
|
173
|
+
assets.length === 0)
|
|
174
|
+
return undefined;
|
|
175
|
+
return {
|
|
176
|
+
mode,
|
|
177
|
+
...(declaration.rendererRevision === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { revision: declaration.rendererRevision }),
|
|
180
|
+
...(rendererSource === undefined ? {} : { document: rendererSource }),
|
|
181
|
+
scripts,
|
|
182
|
+
assets,
|
|
183
|
+
trustedLocalCode: binding.scriptsEnabled,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function resolvePlayerViewRenderer(panel, binding) {
|
|
187
|
+
const rendererSource = panel.renderer
|
|
188
|
+
? binding.files[panel.renderer]
|
|
189
|
+
: undefined;
|
|
190
|
+
if (panel.renderer !== undefined && rendererSource === undefined)
|
|
191
|
+
throw new Error("The frozen player-view renderer resource does not exist");
|
|
192
|
+
const scriptSources = (panel.scripts ?? []).map((path) => {
|
|
193
|
+
const source = binding.files[path];
|
|
194
|
+
if (source === undefined)
|
|
195
|
+
throw new Error("The frozen player-view script resource does not exist");
|
|
196
|
+
return source;
|
|
197
|
+
});
|
|
198
|
+
const scripts = binding.scriptsEnabled ? scriptSources : [];
|
|
199
|
+
const assets = (panel.assets ?? []).map((path) => {
|
|
200
|
+
const source = binding.files[path];
|
|
201
|
+
if (source === undefined)
|
|
202
|
+
throw new Error("The frozen player-view asset resource does not exist");
|
|
203
|
+
return { id: path, source };
|
|
204
|
+
});
|
|
205
|
+
if (rendererSource === undefined &&
|
|
206
|
+
scripts.length === 0 &&
|
|
207
|
+
assets.length === 0)
|
|
208
|
+
return undefined;
|
|
209
|
+
return {
|
|
210
|
+
mode: panel.rendererMode,
|
|
211
|
+
...(panel.rendererRevision === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { revision: panel.rendererRevision }),
|
|
214
|
+
...(rendererSource === undefined ? {} : { document: rendererSource }),
|
|
215
|
+
scripts,
|
|
216
|
+
assets,
|
|
217
|
+
trustedLocalCode: binding.scriptsEnabled,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function missingBundle(preset, diagnostic, status = "missing_revision") {
|
|
221
|
+
return {
|
|
222
|
+
status,
|
|
223
|
+
preset,
|
|
224
|
+
regex: [],
|
|
225
|
+
trustedLocalCode: false,
|
|
226
|
+
fallback: "raw",
|
|
227
|
+
diagnostic,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { projectPlayerViewPanelForFrontend, } from "./FrontendExtensionBundle.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deep projection seam for author-declared panels backed by committed player
|
|
4
|
+
* views. The projector only receives the already-safe view result; it never
|
|
5
|
+
* opens world files or guesses a selector. The selected preset is explicit
|
|
6
|
+
* current-preset state, unlike operation-frozen artifact bundles.
|
|
7
|
+
*/
|
|
8
|
+
export function projectPlayerViewPanels(input) {
|
|
9
|
+
return input.binding.definition.playerViewPanels.map((panel) => projectPanel(panel, input));
|
|
10
|
+
}
|
|
11
|
+
function projectPanel(panel, input) {
|
|
12
|
+
const view = input.playerViews.views.find((candidate) => candidate.id === panel.source.view);
|
|
13
|
+
const diagnostics = input.playerViews.diagnostics
|
|
14
|
+
.filter((diagnostic) => diagnostic.viewId === undefined ||
|
|
15
|
+
diagnostic.viewId === panel.source.view)
|
|
16
|
+
.map((diagnostic) => ({ ...diagnostic }));
|
|
17
|
+
const items = selectItems(view, panel);
|
|
18
|
+
const addUnresolved = (itemId, message) => {
|
|
19
|
+
if (diagnostics.some((diagnostic) => diagnostic.code === "unresolved_selector" &&
|
|
20
|
+
diagnostic.viewId === panel.source.view &&
|
|
21
|
+
diagnostic.itemId === itemId))
|
|
22
|
+
return;
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
code: "unresolved_selector",
|
|
25
|
+
viewId: panel.source.view,
|
|
26
|
+
...(itemId === undefined ? {} : { itemId }),
|
|
27
|
+
message,
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
if (view === undefined)
|
|
31
|
+
addUnresolved(undefined, `The exact player view cannot currently be resolved: ${panel.source.view}`);
|
|
32
|
+
const selectedItemIds = new Set(items.map(({ id }) => id));
|
|
33
|
+
for (const itemId of panel.source.itemIds ?? [])
|
|
34
|
+
if (!selectedItemIds.has(itemId))
|
|
35
|
+
addUnresolved(itemId, `The exact selector cannot currently be resolved: ${panel.source.view}/${itemId}`);
|
|
36
|
+
for (const group of panel.config.groups)
|
|
37
|
+
for (const itemId of group.itemIds)
|
|
38
|
+
if (!selectedItemIds.has(itemId))
|
|
39
|
+
addUnresolved(itemId, `An item referenced by the player-view group cannot currently be resolved: ${panel.source.view}/${itemId}`);
|
|
40
|
+
const frontend = projectPlayerViewPanelForFrontend(panel, input.binding);
|
|
41
|
+
const payload = {
|
|
42
|
+
panelId: panel.id,
|
|
43
|
+
title: panel.config.title ?? view?.title ?? panel.source.view,
|
|
44
|
+
viewId: panel.source.view,
|
|
45
|
+
items: items.map(({ id, label, value }) => ({
|
|
46
|
+
id,
|
|
47
|
+
label,
|
|
48
|
+
value: toArtifactPayload(value),
|
|
49
|
+
})),
|
|
50
|
+
config: {
|
|
51
|
+
layout: panel.config.layout,
|
|
52
|
+
theme: panel.config.theme,
|
|
53
|
+
empty: panel.config.empty,
|
|
54
|
+
emptyMessage: panel.config.emptyMessage,
|
|
55
|
+
groups: panel.config.groups.map((group) => ({
|
|
56
|
+
id: group.id,
|
|
57
|
+
label: group.label,
|
|
58
|
+
itemIds: [...group.itemIds],
|
|
59
|
+
})),
|
|
60
|
+
},
|
|
61
|
+
diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
62
|
+
provenance: {
|
|
63
|
+
kind: "committed_player_view_projection",
|
|
64
|
+
lifecycle: "current_preset",
|
|
65
|
+
head: input.head,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
panelId: panel.id,
|
|
70
|
+
worldId: input.worldId,
|
|
71
|
+
preset: { id: input.binding.id, revision: input.binding.revision },
|
|
72
|
+
lifecycle: "current_preset",
|
|
73
|
+
source: {
|
|
74
|
+
kind: "player_view",
|
|
75
|
+
viewId: panel.source.view,
|
|
76
|
+
...(panel.source.itemIds === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { itemIds: [...panel.source.itemIds] }),
|
|
79
|
+
},
|
|
80
|
+
authority: "committed_player_view_projection",
|
|
81
|
+
head: input.head,
|
|
82
|
+
channel: panel.channel,
|
|
83
|
+
key: panel.key,
|
|
84
|
+
contentType: "application/json",
|
|
85
|
+
payload,
|
|
86
|
+
projection: "upsert",
|
|
87
|
+
diagnostics,
|
|
88
|
+
frontend,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function selectItems(view, panel) {
|
|
92
|
+
if (view === undefined)
|
|
93
|
+
return [];
|
|
94
|
+
if (panel.source.itemIds === undefined)
|
|
95
|
+
return view.items;
|
|
96
|
+
const byId = new Map(view.items.map((item) => [item.id, item]));
|
|
97
|
+
return panel.source.itemIds.flatMap((id) => {
|
|
98
|
+
const item = byId.get(id);
|
|
99
|
+
return item === undefined ? [] : [item];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function toArtifactPayload(value, depth = 0) {
|
|
103
|
+
if (depth > 16)
|
|
104
|
+
return "[UI depth limit reached]";
|
|
105
|
+
if (value === null ||
|
|
106
|
+
typeof value === "string" ||
|
|
107
|
+
typeof value === "number" ||
|
|
108
|
+
typeof value === "boolean")
|
|
109
|
+
return value;
|
|
110
|
+
if (Array.isArray(value))
|
|
111
|
+
return value.map((entry) => toArtifactPayload(entry, depth + 1));
|
|
112
|
+
if (isRecord(value))
|
|
113
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
114
|
+
key,
|
|
115
|
+
toArtifactPayload(child, depth + 1),
|
|
116
|
+
]));
|
|
117
|
+
return "[Unable to display]";
|
|
118
|
+
}
|
|
119
|
+
function isRecord(value) {
|
|
120
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
121
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, chmod, mkdir, readFile, rename, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
export const aiFailureLogFormat = "narraeon.ai-failure-log/v1";
|
|
5
|
+
/**
|
|
6
|
+
* Append-only, failure-triggered Provider diagnostics.
|
|
7
|
+
*
|
|
8
|
+
* No successful request creates a file. Once an operation fails, the triggering
|
|
9
|
+
* exchange and every later exchange are retained until the caller marks the
|
|
10
|
+
* operation repaired. State files make that recording window survive a Runtime
|
|
11
|
+
* restart. API credentials and request headers never enter the capture.
|
|
12
|
+
*/
|
|
13
|
+
export class FileNativeAiFailureLog {
|
|
14
|
+
#root;
|
|
15
|
+
#stateRoot;
|
|
16
|
+
constructor(root) {
|
|
17
|
+
this.#root = root;
|
|
18
|
+
this.#stateRoot = join(root, ".state");
|
|
19
|
+
}
|
|
20
|
+
async recordFailure(input) {
|
|
21
|
+
if (input.failures.length === 0)
|
|
22
|
+
return;
|
|
23
|
+
await this.#bestEffort(async () => {
|
|
24
|
+
const traceKey = failureTraceKey(input.exchange);
|
|
25
|
+
let state = await this.#readState(traceKey);
|
|
26
|
+
if (state === null || state.status === "resolved")
|
|
27
|
+
state = await this.#startIncident(traceKey, input.exchange);
|
|
28
|
+
else
|
|
29
|
+
state = await this.#appendExchange(state, input.exchange);
|
|
30
|
+
await this.#appendLine(state, {
|
|
31
|
+
type: "failure",
|
|
32
|
+
recordedAt: new Date().toISOString(),
|
|
33
|
+
failures: cloneJson(input.failures),
|
|
34
|
+
});
|
|
35
|
+
await this.#writeState({
|
|
36
|
+
...state,
|
|
37
|
+
status: "active",
|
|
38
|
+
updatedAt: new Date().toISOString(),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async recordExchangeIfActive(exchange) {
|
|
43
|
+
await this.#bestEffort(async () => {
|
|
44
|
+
const state = await this.#readState(failureTraceKey(exchange));
|
|
45
|
+
if (state?.status !== "active")
|
|
46
|
+
return;
|
|
47
|
+
await this.#appendExchange(state, exchange);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async resolve(input) {
|
|
51
|
+
await this.#bestEffort(async () => {
|
|
52
|
+
const traceKey = failureTraceKey(input.exchange);
|
|
53
|
+
const current = await this.#readState(traceKey);
|
|
54
|
+
if (current?.status !== "active")
|
|
55
|
+
return;
|
|
56
|
+
const state = await this.#appendExchange(current, input.exchange);
|
|
57
|
+
const recordedAt = new Date().toISOString();
|
|
58
|
+
await this.#appendLine(state, {
|
|
59
|
+
type: "resolved",
|
|
60
|
+
recordedAt,
|
|
61
|
+
message: input.message,
|
|
62
|
+
...(input.details === undefined
|
|
63
|
+
? {}
|
|
64
|
+
: { details: cloneJson(input.details) }),
|
|
65
|
+
});
|
|
66
|
+
await this.#writeState({
|
|
67
|
+
...state,
|
|
68
|
+
status: "resolved",
|
|
69
|
+
updatedAt: recordedAt,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async #startIncident(traceKey, exchange) {
|
|
74
|
+
await this.#prepareRoots();
|
|
75
|
+
const incidentId = randomUUID();
|
|
76
|
+
const startedAt = new Date().toISOString();
|
|
77
|
+
const logFile = `${fileTimestamp(startedAt)}-${incidentId}.jsonl`;
|
|
78
|
+
const state = {
|
|
79
|
+
format: aiFailureLogFormat,
|
|
80
|
+
traceKey,
|
|
81
|
+
incidentId,
|
|
82
|
+
logFile,
|
|
83
|
+
status: "active",
|
|
84
|
+
lastCaptureId: exchange.captureId,
|
|
85
|
+
updatedAt: startedAt,
|
|
86
|
+
};
|
|
87
|
+
const initial = [
|
|
88
|
+
{
|
|
89
|
+
type: "incident",
|
|
90
|
+
format: aiFailureLogFormat,
|
|
91
|
+
incidentId,
|
|
92
|
+
traceKey,
|
|
93
|
+
startedAt,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "exchange",
|
|
97
|
+
recordedAt: startedAt,
|
|
98
|
+
exchange: cloneJson(exchange),
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
.map((value) => JSON.stringify(value))
|
|
102
|
+
.join("\n");
|
|
103
|
+
await writeFile(join(this.#root, logFile), `${initial}\n`, {
|
|
104
|
+
encoding: "utf8",
|
|
105
|
+
mode: 0o600,
|
|
106
|
+
flag: "wx",
|
|
107
|
+
});
|
|
108
|
+
await chmod(join(this.#root, logFile), 0o600);
|
|
109
|
+
await this.#writeState(state);
|
|
110
|
+
return state;
|
|
111
|
+
}
|
|
112
|
+
async #appendExchange(state, exchange) {
|
|
113
|
+
if (state.lastCaptureId === exchange.captureId)
|
|
114
|
+
return state;
|
|
115
|
+
const recordedAt = new Date().toISOString();
|
|
116
|
+
await this.#appendLine(state, {
|
|
117
|
+
type: "exchange",
|
|
118
|
+
recordedAt,
|
|
119
|
+
exchange: cloneJson(exchange),
|
|
120
|
+
});
|
|
121
|
+
const next = {
|
|
122
|
+
...state,
|
|
123
|
+
lastCaptureId: exchange.captureId,
|
|
124
|
+
updatedAt: recordedAt,
|
|
125
|
+
};
|
|
126
|
+
await this.#writeState(next);
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
async #appendLine(state, value) {
|
|
130
|
+
const path = join(this.#root, basename(state.logFile));
|
|
131
|
+
await appendFile(path, `${JSON.stringify(value)}\n`, {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
mode: 0o600,
|
|
134
|
+
});
|
|
135
|
+
await chmod(path, 0o600);
|
|
136
|
+
}
|
|
137
|
+
async #readState(traceKey) {
|
|
138
|
+
const path = this.#statePath(traceKey);
|
|
139
|
+
let source;
|
|
140
|
+
try {
|
|
141
|
+
source = await readFile(path, "utf8");
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (hasErrorCode(error, "ENOENT"))
|
|
145
|
+
return null;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const value = JSON.parse(source);
|
|
149
|
+
if (!isIncidentState(value, traceKey))
|
|
150
|
+
return null;
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
async #writeState(state) {
|
|
154
|
+
await this.#prepareRoots();
|
|
155
|
+
const path = this.#statePath(state.traceKey);
|
|
156
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
157
|
+
await writeFile(temporary, `${JSON.stringify(state)}\n`, {
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
mode: 0o600,
|
|
160
|
+
flag: "wx",
|
|
161
|
+
});
|
|
162
|
+
await rename(temporary, path);
|
|
163
|
+
await chmod(path, 0o600);
|
|
164
|
+
}
|
|
165
|
+
async #prepareRoots() {
|
|
166
|
+
await mkdir(this.#root, { recursive: true, mode: 0o700 });
|
|
167
|
+
await mkdir(this.#stateRoot, { recursive: true, mode: 0o700 });
|
|
168
|
+
await Promise.all([
|
|
169
|
+
chmod(this.#root, 0o700),
|
|
170
|
+
chmod(this.#stateRoot, 0o700),
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
#statePath(traceKey) {
|
|
174
|
+
const name = createHash("sha256").update(traceKey).digest("hex");
|
|
175
|
+
return join(this.#stateRoot, `${name}.json`);
|
|
176
|
+
}
|
|
177
|
+
async #bestEffort(operation) {
|
|
178
|
+
try {
|
|
179
|
+
await operation();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Diagnostics must never change Provider or Runtime authority semantics.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Mutable capture used only for the lifetime of one Provider request. */
|
|
187
|
+
export class AiExchangeCapture {
|
|
188
|
+
#base;
|
|
189
|
+
#responseChunks = [];
|
|
190
|
+
#response;
|
|
191
|
+
#reasoning = "";
|
|
192
|
+
constructor(input) {
|
|
193
|
+
this.#base = {
|
|
194
|
+
captureId: randomUUID(),
|
|
195
|
+
provider: input.provider,
|
|
196
|
+
endpoint: safeEndpoint(input.endpoint),
|
|
197
|
+
context: cloneJson(input.context),
|
|
198
|
+
request: {
|
|
199
|
+
method: "POST",
|
|
200
|
+
contentType: "application/json",
|
|
201
|
+
body: input.requestBody,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
observeReasoning(text) {
|
|
206
|
+
this.#reasoning += text;
|
|
207
|
+
}
|
|
208
|
+
setReasoning(text) {
|
|
209
|
+
if (text !== undefined)
|
|
210
|
+
this.#reasoning = text;
|
|
211
|
+
}
|
|
212
|
+
captureResponse(response) {
|
|
213
|
+
this.#response = {
|
|
214
|
+
status: response.status,
|
|
215
|
+
statusText: response.statusText,
|
|
216
|
+
contentType: response.headers.get("content-type"),
|
|
217
|
+
bodyComplete: response.body === null,
|
|
218
|
+
};
|
|
219
|
+
if (response.body === null)
|
|
220
|
+
return response;
|
|
221
|
+
const reader = response.body.getReader();
|
|
222
|
+
const captured = new ReadableStream({
|
|
223
|
+
pull: async (controller) => {
|
|
224
|
+
try {
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done) {
|
|
227
|
+
if (this.#response !== undefined)
|
|
228
|
+
this.#response.bodyComplete = true;
|
|
229
|
+
controller.close();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.#responseChunks.push(Buffer.from(value));
|
|
233
|
+
controller.enqueue(value);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
controller.error(error);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
cancel: async (reason) => {
|
|
240
|
+
try {
|
|
241
|
+
await reader.cancel(reason);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// The caller's Provider classification remains authoritative.
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
return new Response(captured, {
|
|
249
|
+
status: response.status,
|
|
250
|
+
statusText: response.statusText,
|
|
251
|
+
headers: response.headers,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
snapshot() {
|
|
255
|
+
return {
|
|
256
|
+
...cloneJson(this.#base),
|
|
257
|
+
...(this.#response === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: {
|
|
260
|
+
response: {
|
|
261
|
+
...cloneJson(this.#response),
|
|
262
|
+
body: Buffer.concat(this.#responseChunks).toString("utf8"),
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
...(this.#reasoning === "" ? {} : { reasoning: this.#reasoning }),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
export function errorDescription(error) {
|
|
270
|
+
if (!(error instanceof Error))
|
|
271
|
+
return { name: "UnknownError", message: String(error) };
|
|
272
|
+
return {
|
|
273
|
+
name: error.name,
|
|
274
|
+
message: error.message,
|
|
275
|
+
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
276
|
+
...(error.cause === undefined ? {} : { cause: describeCause(error.cause) }),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function describeCause(cause) {
|
|
280
|
+
if (cause instanceof Error)
|
|
281
|
+
return `${cause.name}: ${cause.message}`;
|
|
282
|
+
if (typeof cause === "string")
|
|
283
|
+
return cause;
|
|
284
|
+
if (typeof cause === "number" ||
|
|
285
|
+
typeof cause === "bigint" ||
|
|
286
|
+
typeof cause === "boolean" ||
|
|
287
|
+
typeof cause === "symbol")
|
|
288
|
+
return String(cause);
|
|
289
|
+
if (cause === null)
|
|
290
|
+
return "null";
|
|
291
|
+
try {
|
|
292
|
+
return JSON.stringify(cause) ?? "undefined";
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return "[unserializable cause]";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function failureTraceKey(exchange) {
|
|
299
|
+
const { scope, operationId, requestId } = exchange.context;
|
|
300
|
+
if (scope === "play_followup")
|
|
301
|
+
return [scope, operationId ?? "", requestId ?? exchange.endpoint].join("\0");
|
|
302
|
+
return [scope, operationId ?? requestId ?? exchange.endpoint].join("\0");
|
|
303
|
+
}
|
|
304
|
+
function safeEndpoint(url) {
|
|
305
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
306
|
+
}
|
|
307
|
+
function fileTimestamp(value) {
|
|
308
|
+
return value.replaceAll(":", "-");
|
|
309
|
+
}
|
|
310
|
+
function cloneJson(value) {
|
|
311
|
+
return JSON.parse(JSON.stringify(value));
|
|
312
|
+
}
|
|
313
|
+
function isIncidentState(value, traceKey) {
|
|
314
|
+
return (typeof value === "object" &&
|
|
315
|
+
value !== null &&
|
|
316
|
+
!Array.isArray(value) &&
|
|
317
|
+
value.format === aiFailureLogFormat &&
|
|
318
|
+
value.traceKey === traceKey &&
|
|
319
|
+
typeof value.incidentId === "string" &&
|
|
320
|
+
typeof value.logFile === "string" &&
|
|
321
|
+
(value.status === "active" ||
|
|
322
|
+
value.status === "resolved") &&
|
|
323
|
+
(value.lastCaptureId === null ||
|
|
324
|
+
typeof value.lastCaptureId === "string") &&
|
|
325
|
+
typeof value.updatedAt === "string");
|
|
326
|
+
}
|
|
327
|
+
function hasErrorCode(error, code) {
|
|
328
|
+
return (typeof error === "object" &&
|
|
329
|
+
error !== null &&
|
|
330
|
+
"code" in error &&
|
|
331
|
+
error.code === code);
|
|
332
|
+
}
|