@playcraft/cli 0.1.85-beta → 0.1.87
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/commands/tools-generation.js +102 -46
- package/dist/commands/tools-request.js +24 -0
- package/dist/commands/tools-utils.js +15 -17
- package/dist/commands/tools.js +2 -0
- package/dist/utils/agent-api-client.js +57 -18
- package/dist/utils/image-asset-context.js +92 -42
- package/dist/utils/tool-operation-journal.js +3 -3
- package/dist/workspace-runtime/codex/config-toml.js +3 -2
- package/dist/workspace-runtime/codex/model-broker-diagnostics.js +149 -0
- package/dist/workspace-runtime/codex/model-broker-request.js +59 -0
- package/dist/workspace-runtime/codex/model-broker.js +221 -84
- package/dist/workspace-runtime/codex/model-capture.js +59 -10
- package/package.json +3 -3
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import JSON5 from 'json5';
|
|
4
4
|
import sharp from 'sharp';
|
|
5
|
-
import { extractAssetFields, getByPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
|
|
5
|
+
import { extractAssetFields, getByPath, parseThemeIndexImportPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_INDEX_PATH, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
|
|
6
6
|
function isWithinDirectory(root, file) {
|
|
7
7
|
const relative = path.relative(root, file);
|
|
8
8
|
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
@@ -38,20 +38,29 @@ export function assertImageAssetOutputPaths(context, outputPaths, projectDir = p
|
|
|
38
38
|
const root = path.resolve(projectDir);
|
|
39
39
|
const target = canonicalProjectPath(root, context.targetAssetPath);
|
|
40
40
|
const targetStat = statSync(target);
|
|
41
|
-
const outputRoots = [
|
|
41
|
+
const outputRoots = [
|
|
42
|
+
root,
|
|
43
|
+
...allowedOutputRoots.map((outputRoot) => path.resolve(root, outputRoot)),
|
|
44
|
+
];
|
|
42
45
|
for (const output of outputPaths) {
|
|
43
46
|
const mapped = output.startsWith('/project/') ? output.slice('/project/'.length) : output;
|
|
44
47
|
const { dir, name } = path.parse(mapped);
|
|
45
48
|
// The response encoding can change the requested extension.
|
|
46
|
-
const candidates = [
|
|
49
|
+
const candidates = [
|
|
50
|
+
mapped,
|
|
51
|
+
...['.png', '.webp', '.jpg', '.gif', '.bmp'].map((ext) => path.join(dir, `${name}${ext}`)),
|
|
52
|
+
];
|
|
47
53
|
for (const candidate of candidates) {
|
|
48
54
|
const candidatePath = path.resolve(root, candidate);
|
|
49
|
-
const outputRoot = outputRoots.find(allowed => isWithinDirectory(allowed, candidatePath));
|
|
55
|
+
const outputRoot = outputRoots.find((allowed) => isWithinDirectory(allowed, candidatePath));
|
|
50
56
|
if (!outputRoot)
|
|
51
57
|
throw new Error(`Image output path is outside the project and allowed output roots: ${output}`);
|
|
52
58
|
const file = canonicalProjectPath(outputRoot, candidatePath);
|
|
53
59
|
const fileStat = existsSync(file) ? statSync(file) : undefined;
|
|
54
|
-
if (!path.relative(target, file) ||
|
|
60
|
+
if (!path.relative(target, file) ||
|
|
61
|
+
(targetStat.ino !== 0 &&
|
|
62
|
+
fileStat?.ino === targetStat.ino &&
|
|
63
|
+
fileStat.dev === targetStat.dev)) {
|
|
55
64
|
throw new Error(`Cannot overwrite shared image asset ${context.targetAssetPath}: other fields have different requirements. Use a separate outputPath, then explicitly update the selected field reference; no shared asset was changed.`);
|
|
56
65
|
}
|
|
57
66
|
}
|
|
@@ -71,8 +80,10 @@ function projectFile(root, input) {
|
|
|
71
80
|
/** Read the same per-field constraints used by the asset image dialog. */
|
|
72
81
|
export async function readImageAssetContext(assetPath, projectDir = process.cwd(), options = {}) {
|
|
73
82
|
const targetFieldPath = options.targetFieldPath;
|
|
74
|
-
if (targetFieldPath !== undefined &&
|
|
75
|
-
|
|
83
|
+
if (targetFieldPath !== undefined &&
|
|
84
|
+
(!Array.isArray(targetFieldPath) ||
|
|
85
|
+
!targetFieldPath.length ||
|
|
86
|
+
targetFieldPath.some((key) => typeof key !== 'string' || !key.length))) {
|
|
76
87
|
throw new Error('targetFieldPath must be a non-empty array of non-empty field names.');
|
|
77
88
|
}
|
|
78
89
|
const root = path.resolve(projectDir);
|
|
@@ -81,24 +92,36 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
81
92
|
const targetStat = statSync(target);
|
|
82
93
|
const relativeTarget = path.relative(root, target).replace(/\\/g, '/');
|
|
83
94
|
const themeMatch = relativeTarget.match(/^src\/theme\/([^/]+)\/assets\//);
|
|
95
|
+
const indexFile = path.join(root, THEME_INDEX_PATH);
|
|
96
|
+
const themeId = themeMatch?.[1] ??
|
|
97
|
+
(existsSync(indexFile)
|
|
98
|
+
? parseThemeIndexImportPath(readFileSync(projectFile(root, THEME_INDEX_PATH), 'utf8'))
|
|
99
|
+
: null);
|
|
84
100
|
let fields = [];
|
|
85
101
|
const schemaFile = path.join(root, THEME_SCHEMA_PATH);
|
|
86
|
-
if (
|
|
102
|
+
if (themeId && existsSync(schemaFile)) {
|
|
87
103
|
const schema = JSON5.parse(readFileSync(projectFile(root, THEME_SCHEMA_PATH), 'utf8'));
|
|
88
|
-
const dataPath = themeDataPath(
|
|
104
|
+
const dataPath = themeDataPath(themeId);
|
|
89
105
|
const data = existsSync(path.join(root, dataPath))
|
|
90
|
-
? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8'))
|
|
106
|
+
? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8'))
|
|
107
|
+
: {};
|
|
91
108
|
const themeRoot = path.dirname(path.join(root, dataPath));
|
|
92
|
-
const matches = extractAssetFields(schema, data).filter(candidate => {
|
|
109
|
+
const matches = extractAssetFields(schema, data).filter((candidate) => {
|
|
93
110
|
let configured = getByPath(data, candidate.path);
|
|
94
111
|
if (configured === undefined && ['gameplay', 'gamePlay'].includes(candidate.path[0])) {
|
|
95
|
-
configured = getByPath(data, [
|
|
112
|
+
configured = getByPath(data, [
|
|
113
|
+
candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay',
|
|
114
|
+
...candidate.path.slice(1),
|
|
115
|
+
]);
|
|
96
116
|
}
|
|
97
117
|
const value = typeof configured === 'string' ? configured : candidate.schema.default;
|
|
98
118
|
if (typeof value !== 'string' || /^https?:\/\//i.test(value))
|
|
99
119
|
return false;
|
|
100
|
-
const configuredPath = value.startsWith('/project/')
|
|
101
|
-
|
|
120
|
+
const configuredPath = value.startsWith('/project/')
|
|
121
|
+
? path.resolve(root, value.slice('/project/'.length))
|
|
122
|
+
: /^\/?src\//.test(value)
|
|
123
|
+
? path.resolve(root, value.replace(/^\//, ''))
|
|
124
|
+
: path.resolve(themeRoot, value);
|
|
102
125
|
if (configuredPath === target)
|
|
103
126
|
return true;
|
|
104
127
|
try {
|
|
@@ -106,19 +129,22 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
106
129
|
if (!path.relative(realTarget, realpathSync(candidateFile)))
|
|
107
130
|
return true;
|
|
108
131
|
const candidateStat = statSync(candidateFile);
|
|
109
|
-
return targetStat.ino !== 0 &&
|
|
132
|
+
return (targetStat.ino !== 0 &&
|
|
133
|
+
candidateStat.ino === targetStat.ino &&
|
|
134
|
+
candidateStat.dev === targetStat.dev);
|
|
110
135
|
}
|
|
111
136
|
catch {
|
|
112
137
|
// Other fields may reference missing files or paths outside this project.
|
|
113
138
|
return false;
|
|
114
139
|
}
|
|
115
140
|
});
|
|
116
|
-
if (!matches.length)
|
|
141
|
+
if (!matches.length && (themeMatch || targetFieldPath))
|
|
117
142
|
throw new Error(`No image schema field matches ${relativeTarget}; check theme.data.json5 before generating.`);
|
|
118
143
|
fields = matches;
|
|
119
|
-
if (targetFieldPath &&
|
|
120
|
-
|
|
121
|
-
|
|
144
|
+
if (targetFieldPath &&
|
|
145
|
+
!fields.some((candidate) => candidate.path.length === targetFieldPath.length &&
|
|
146
|
+
candidate.path.every((key, index) => key === targetFieldPath[index]))) {
|
|
147
|
+
throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map((candidate) => JSON.stringify(candidate.path)).join(', ')}.`);
|
|
122
148
|
}
|
|
123
149
|
}
|
|
124
150
|
else if (targetFieldPath) {
|
|
@@ -131,22 +157,35 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
131
157
|
const ext = xp?.ext;
|
|
132
158
|
// Schema references follow the dialog's root/theme conventions; explicit tool paths do not change.
|
|
133
159
|
const schemaReferences = Array.isArray(ext?.reference_image)
|
|
134
|
-
? ext.reference_image
|
|
160
|
+
? ext.reference_image
|
|
161
|
+
.filter((ref) => typeof ref === 'string' && ref.trim())
|
|
162
|
+
.map((ref) => {
|
|
135
163
|
const raw = ref.trim().replace(/\\/g, '/');
|
|
136
|
-
const normalized = raw
|
|
164
|
+
const normalized = raw
|
|
165
|
+
.replace(/^\.\/+/, '')
|
|
166
|
+
.replace(/^\/+/, '')
|
|
167
|
+
.replace(/\/{2,}/g, '/');
|
|
137
168
|
if (/^\/?(?:assets|src|public)\//.test(raw) || normalized.startsWith('src/'))
|
|
138
169
|
return normalized;
|
|
139
|
-
if (!
|
|
170
|
+
if (!themeId || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
|
|
140
171
|
return raw;
|
|
141
|
-
return path.posix.join('src/theme',
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
return path.posix.join('src/theme', themeId, normalized);
|
|
173
|
+
})
|
|
174
|
+
: [];
|
|
175
|
+
const references = options.referenceImagePaths?.length
|
|
176
|
+
? options.referenceImagePaths
|
|
177
|
+
: options.useTargetReference === false
|
|
178
|
+
? []
|
|
179
|
+
: schemaReferences.length
|
|
180
|
+
? schemaReferences
|
|
181
|
+
: [relativeTarget];
|
|
182
|
+
const referenceImagePaths = references.map((ref) => /^https?:\/\//i.test(ref)
|
|
183
|
+
? ref
|
|
146
184
|
: path.relative(realpathSync(root), canonicalProjectPath(root, ref)).replace(/\\/g, '/'));
|
|
147
185
|
const hasSchemaBackground = typeof ext?.remove_background === 'boolean';
|
|
148
186
|
if (!hasSchemaBackground && targetHasTransparency === undefined) {
|
|
149
|
-
targetHasTransparency =
|
|
187
|
+
targetHasTransparency =
|
|
188
|
+
!!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
|
|
150
189
|
}
|
|
151
190
|
const removeBackground = resolveImageBackgroundRequirement({
|
|
152
191
|
prompt: options.prompt ?? '',
|
|
@@ -171,12 +210,16 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
171
210
|
targetAssetPath: relativeTarget,
|
|
172
211
|
fieldPath: field?.path,
|
|
173
212
|
referenceImagePaths,
|
|
174
|
-
width: delivery.width,
|
|
213
|
+
width: delivery.width,
|
|
214
|
+
height: delivery.height,
|
|
175
215
|
aspectRatio: typeof ext?.ratio === 'string' ? ext.ratio : `${delivery.width}:${delivery.height}`,
|
|
176
216
|
outputFormat: (removeBackground && delivery.format === 'jpeg' && !delivery.fromSchemaMimeType
|
|
177
|
-
? 'png'
|
|
217
|
+
? 'png'
|
|
218
|
+
: delivery.format),
|
|
178
219
|
outputFormatFromSchema: delivery.fromSchemaMimeType,
|
|
179
|
-
fitMode: delivery.fitMode,
|
|
220
|
+
fitMode: delivery.fitMode,
|
|
221
|
+
removeBackground: delivery.removeBackground,
|
|
222
|
+
paddingColor: delivery.paddingColor,
|
|
180
223
|
removeBackgroundFromSchema: hasSchemaBackground,
|
|
181
224
|
imageModelRef: typeof ext?.imageModelRef === 'string' ? ext.imageModelRef : undefined,
|
|
182
225
|
};
|
|
@@ -188,33 +231,40 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
188
231
|
contexts.push(await contextForField(field));
|
|
189
232
|
}
|
|
190
233
|
catch (error) {
|
|
191
|
-
const selected = field?.path.length === targetFieldPath?.length
|
|
192
|
-
|
|
234
|
+
const selected = field?.path.length === targetFieldPath?.length &&
|
|
235
|
+
field?.path.every((key, index) => key === targetFieldPath?.[index]);
|
|
193
236
|
if (!targetFieldPath || selected)
|
|
194
237
|
throw error;
|
|
195
238
|
// Invalid sibling requirements still protect the original shared file.
|
|
196
239
|
unresolvedSharedField = true;
|
|
197
240
|
}
|
|
198
241
|
}
|
|
199
|
-
const context = targetFieldPath
|
|
200
|
-
|
|
242
|
+
const context = targetFieldPath
|
|
243
|
+
? contexts.find((candidate) => candidate.fieldPath?.length === targetFieldPath.length &&
|
|
244
|
+
candidate.fieldPath.every((key, index) => key === targetFieldPath[index]))
|
|
245
|
+
: contexts[0];
|
|
201
246
|
const deliveryKey = ({ fieldPath: _fieldPath, outputFormatFromSchema: _formatSource, removeBackgroundFromSchema: _backgroundSource, aspectRatio, ...delivery }) => {
|
|
202
247
|
const ratio = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
|
|
203
|
-
return JSON.stringify({
|
|
248
|
+
return JSON.stringify({
|
|
249
|
+
...delivery,
|
|
250
|
+
aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio,
|
|
251
|
+
});
|
|
204
252
|
};
|
|
205
|
-
const conflicting = unresolvedSharedField ||
|
|
253
|
+
const conflicting = unresolvedSharedField ||
|
|
254
|
+
contexts.some((candidate) => deliveryKey(candidate) !== deliveryKey(context));
|
|
206
255
|
if (conflicting && !targetFieldPath) {
|
|
207
|
-
throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map(field => JSON.stringify(field.path)).join(', ')}.`);
|
|
256
|
+
throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map((field) => JSON.stringify(field.path)).join(', ')}.`);
|
|
208
257
|
}
|
|
209
258
|
if (targetFieldPath) {
|
|
210
259
|
// A separate output retains exactly the selected field's requirements.
|
|
211
|
-
if (conflicting ||
|
|
212
|
-
|
|
260
|
+
if (conflicting ||
|
|
261
|
+
contexts.some((candidate) => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema) ||
|
|
262
|
+
(candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
|
|
213
263
|
context.requiresSeparateOutput = true;
|
|
214
264
|
}
|
|
215
265
|
else {
|
|
216
|
-
context.outputFormatFromSchema = contexts.some(candidate => candidate.outputFormatFromSchema);
|
|
217
|
-
context.removeBackgroundFromSchema = contexts.some(candidate => candidate.removeBackgroundFromSchema);
|
|
266
|
+
context.outputFormatFromSchema = contexts.some((candidate) => candidate.outputFormatFromSchema);
|
|
267
|
+
context.removeBackgroundFromSchema = contexts.some((candidate) => candidate.removeBackgroundFromSchema);
|
|
218
268
|
}
|
|
219
269
|
assertImageAssetOutputPaths(context, options.outputPaths ?? [], root, options.allowedOutputRoots);
|
|
220
270
|
if (context.referenceImagePaths.length > 8)
|
|
@@ -29,7 +29,7 @@ function holderAlive(holder) {
|
|
|
29
29
|
}
|
|
30
30
|
/** Persist an identity before sending. Store neither credentials nor request/response bodies. */
|
|
31
31
|
export function beginToolOperation(path, serializedBody, recoverId, options = {}) {
|
|
32
|
-
const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
32
|
+
const directory = options.directory ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
33
33
|
if (!isAbsolute(directory))
|
|
34
34
|
throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
|
|
35
35
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
@@ -98,8 +98,8 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
/** Read an existing identity without creating an intent or changing its receipt state. */
|
|
101
|
-
export function findToolOperation(path, serializedBody, requestId) {
|
|
102
|
-
const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
101
|
+
export function findToolOperation(path, serializedBody, requestId, directoryOverride) {
|
|
102
|
+
const directory = directoryOverride ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
103
103
|
if (!isAbsolute(directory))
|
|
104
104
|
throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
|
|
105
105
|
const inputHash = createHash('sha256').update(serializedBody).digest('hex');
|
|
@@ -12,6 +12,7 @@ export function portableCodexConfig(catalogPath) {
|
|
|
12
12
|
return {
|
|
13
13
|
web_search: 'disabled',
|
|
14
14
|
'features.multi_agent': true,
|
|
15
|
+
'features.unbounded_connection_retries': false,
|
|
15
16
|
'features.goals': false,
|
|
16
17
|
'features.apps': false,
|
|
17
18
|
'features.plugins': false,
|
|
@@ -82,8 +83,8 @@ export async function writePortableCodexCatalog(input) {
|
|
|
82
83
|
await writeFile(path, body, { encoding: 'utf8', mode: 0o644 }); // shared
|
|
83
84
|
await chmod(path, 0o644);
|
|
84
85
|
const versionPath = join(input.directory, 'portable-models.version.json');
|
|
85
|
-
|
|
86
|
-
versionPath, JSON.stringify({
|
|
86
|
+
// shared: version metadata sits beside the catalog, readable by both sandbox identities.
|
|
87
|
+
await writeFile(versionPath, JSON.stringify({
|
|
87
88
|
version,
|
|
88
89
|
sha256: createHash('sha256').update(body).digest('hex'),
|
|
89
90
|
}), { encoding: 'utf8', mode: 0o644 });
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const WAITING_LOG_MS = 30_000;
|
|
2
|
+
const errorNames = new Set([
|
|
3
|
+
'Error',
|
|
4
|
+
'TypeError',
|
|
5
|
+
'SyntaxError',
|
|
6
|
+
'AbortError',
|
|
7
|
+
'TimeoutError',
|
|
8
|
+
'AggregateError',
|
|
9
|
+
'HeadersTimeoutError',
|
|
10
|
+
'BodyTimeoutError',
|
|
11
|
+
'SocketError',
|
|
12
|
+
'ConnectTimeoutError',
|
|
13
|
+
]);
|
|
14
|
+
/** Error messages may contain request bodies or credentials; retain only bounded classifications. */
|
|
15
|
+
function errorChain(error) {
|
|
16
|
+
const chain = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
let current = error;
|
|
19
|
+
for (let depth = 0; depth < 5 && current && typeof current === 'object' && !seen.has(current); depth++) {
|
|
20
|
+
seen.add(current);
|
|
21
|
+
try {
|
|
22
|
+
const value = current;
|
|
23
|
+
const name = typeof value.name === 'string' && errorNames.has(value.name) ? value.name : undefined;
|
|
24
|
+
const code = typeof value.code === 'string' &&
|
|
25
|
+
/^(?:UND_ERR_|ERR_|E[A-Z]|CERT_|DEPTH_|UNABLE_|SELF_SIGNED_)[A-Z0-9_]{1,80}$/.test(value.code)
|
|
26
|
+
? value.code
|
|
27
|
+
: typeof value.message === 'string' &&
|
|
28
|
+
/^(?:model_(?:access_denied|credential_(?:unavailable|scope_mismatch|expired|timeout|invalid)|config_required|config_denied|request_(?:deadline_exceeded|cancelled|transport_error|invalid_json|too_large))|reasoning_effort_(?:denied|unsupported))$/.test(value.message)
|
|
29
|
+
? value.message
|
|
30
|
+
: undefined;
|
|
31
|
+
if (name || code)
|
|
32
|
+
chain.push({ name, code });
|
|
33
|
+
current = value.cause;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return chain;
|
|
40
|
+
}
|
|
41
|
+
/** Per-request metadata only; observing a stalled transfer never cancels or retries it. */
|
|
42
|
+
export class ModelBrokerDiagnostics {
|
|
43
|
+
context;
|
|
44
|
+
started = performance.now();
|
|
45
|
+
stageStarted = this.started;
|
|
46
|
+
lastProgress = this.started;
|
|
47
|
+
stage = 'credential';
|
|
48
|
+
requestBytes = 0;
|
|
49
|
+
responseBytes = 0;
|
|
50
|
+
upstreamStatus;
|
|
51
|
+
abortReason;
|
|
52
|
+
responseIds = {};
|
|
53
|
+
timer;
|
|
54
|
+
constructor(context) {
|
|
55
|
+
this.context = context;
|
|
56
|
+
this.write('received');
|
|
57
|
+
this.timer = setInterval(() => {
|
|
58
|
+
if (performance.now() - this.lastProgress >= WAITING_LOG_MS)
|
|
59
|
+
this.write('waiting');
|
|
60
|
+
}, WAITING_LOG_MS);
|
|
61
|
+
this.timer.unref();
|
|
62
|
+
}
|
|
63
|
+
bind(context) {
|
|
64
|
+
Object.assign(this.context, context);
|
|
65
|
+
}
|
|
66
|
+
enter(stage) {
|
|
67
|
+
this.stage = stage;
|
|
68
|
+
this.stageStarted = this.lastProgress = performance.now();
|
|
69
|
+
this.write('stage');
|
|
70
|
+
}
|
|
71
|
+
requestChunk(bytes) {
|
|
72
|
+
this.requestBytes += bytes;
|
|
73
|
+
this.lastProgress = performance.now();
|
|
74
|
+
}
|
|
75
|
+
headers(status, headers) {
|
|
76
|
+
this.upstreamStatus = status;
|
|
77
|
+
for (const [header, field] of [
|
|
78
|
+
['x-model-attempt-id', 'attemptId'],
|
|
79
|
+
['x-request-id', 'proxyRequestId'],
|
|
80
|
+
['eo-log-uuid', 'edgeOneRequestId'],
|
|
81
|
+
['x-kong-request-id', 'kongRequestId'],
|
|
82
|
+
]) {
|
|
83
|
+
const value = headers.get(header);
|
|
84
|
+
if (value && /^[A-Za-z0-9._:-]{1,128}$/.test(value))
|
|
85
|
+
this.responseIds[field] = value;
|
|
86
|
+
}
|
|
87
|
+
for (const [header, field] of [
|
|
88
|
+
['x-kong-proxy-latency', 'kongProxyLatencyMs'],
|
|
89
|
+
['x-kong-upstream-latency', 'kongUpstreamLatencyMs'],
|
|
90
|
+
]) {
|
|
91
|
+
const value = headers.get(header);
|
|
92
|
+
if (value && /^\d{1,9}$/.test(value))
|
|
93
|
+
this.responseIds[field] = Number(value);
|
|
94
|
+
}
|
|
95
|
+
this.write('headers');
|
|
96
|
+
this.enter('response_body');
|
|
97
|
+
}
|
|
98
|
+
responseChunk(bytes) {
|
|
99
|
+
this.responseBytes += bytes;
|
|
100
|
+
this.lastProgress = performance.now();
|
|
101
|
+
if (bytes && this.responseBytes === bytes)
|
|
102
|
+
this.write('first_byte');
|
|
103
|
+
}
|
|
104
|
+
abort(reason) {
|
|
105
|
+
this.abortReason ??= reason;
|
|
106
|
+
}
|
|
107
|
+
finish(status, downstreamFinished, aborted, error) {
|
|
108
|
+
clearInterval(this.timer);
|
|
109
|
+
const outcome = error !== undefined
|
|
110
|
+
? (this.abortReason ?? (aborted ? 'interrupted' : 'error'))
|
|
111
|
+
: this.upstreamStatus !== undefined && this.upstreamStatus >= 400
|
|
112
|
+
? 'upstream_http_error'
|
|
113
|
+
: status >= 400
|
|
114
|
+
? 'rejected'
|
|
115
|
+
: 'completed';
|
|
116
|
+
this.write('finished', {
|
|
117
|
+
outcome,
|
|
118
|
+
responseStatus: status,
|
|
119
|
+
downstreamFinished,
|
|
120
|
+
cancelled: aborted && !downstreamFinished,
|
|
121
|
+
...(error !== undefined ? { errors: errorChain(error) } : {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
write(observation, extra = {}) {
|
|
125
|
+
const now = performance.now();
|
|
126
|
+
try {
|
|
127
|
+
console.log(JSON.stringify({
|
|
128
|
+
event: 'model.broker.request',
|
|
129
|
+
time: Date.now(),
|
|
130
|
+
service: 'workspace-runtime',
|
|
131
|
+
...this.context,
|
|
132
|
+
observation,
|
|
133
|
+
stage: this.stage,
|
|
134
|
+
elapsedMs: Math.max(0, Math.round(now - this.started)),
|
|
135
|
+
stageElapsedMs: Math.max(0, Math.round(now - this.stageStarted)),
|
|
136
|
+
idleMs: Math.max(0, Math.round(now - this.lastProgress)),
|
|
137
|
+
requestBytes: this.requestBytes,
|
|
138
|
+
responseBytes: this.responseBytes,
|
|
139
|
+
upstreamStatus: this.upstreamStatus,
|
|
140
|
+
abortReason: this.abortReason,
|
|
141
|
+
...this.responseIds,
|
|
142
|
+
...extra,
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* Diagnostic output must not alter model execution. */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const MODEL_REQUEST_BUDGET_MS = 150_000;
|
|
2
|
+
export class ModelBrokerRequestError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code, cause) {
|
|
6
|
+
super(code, { cause });
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Stop reading on cancellation without destroying the socket before its error response is sent. */
|
|
12
|
+
export function readModelRequestBody(request, signal, append) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const cleanup = () => {
|
|
15
|
+
request.off('data', data);
|
|
16
|
+
request.off('end', end);
|
|
17
|
+
request.off('error', fail);
|
|
18
|
+
signal.removeEventListener('abort', abort);
|
|
19
|
+
};
|
|
20
|
+
const fail = (error) => {
|
|
21
|
+
cleanup();
|
|
22
|
+
request.pause();
|
|
23
|
+
reject(error);
|
|
24
|
+
};
|
|
25
|
+
const data = (chunk) => {
|
|
26
|
+
try {
|
|
27
|
+
append(chunk);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
fail(error);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const end = () => {
|
|
34
|
+
cleanup();
|
|
35
|
+
resolve();
|
|
36
|
+
};
|
|
37
|
+
const abort = () => fail(signal.reason);
|
|
38
|
+
request.on('data', data);
|
|
39
|
+
request.once('end', end);
|
|
40
|
+
request.once('error', fail);
|
|
41
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
42
|
+
if (signal.aborted)
|
|
43
|
+
abort();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Public errors contain stable classifications, never arbitrary transport or response text. */
|
|
47
|
+
export function modelBrokerFailure(error, signal) {
|
|
48
|
+
if (signal.aborted) {
|
|
49
|
+
return signal.reason instanceof ModelBrokerRequestError
|
|
50
|
+
? signal.reason
|
|
51
|
+
: new ModelBrokerRequestError(499, 'model_request_cancelled');
|
|
52
|
+
}
|
|
53
|
+
if (error instanceof ModelBrokerRequestError)
|
|
54
|
+
return error;
|
|
55
|
+
if (error instanceof Error && error.message === 'model_access_denied') {
|
|
56
|
+
return new ModelBrokerRequestError(403, error.message);
|
|
57
|
+
}
|
|
58
|
+
return new ModelBrokerRequestError(502, 'model_request_transport_error');
|
|
59
|
+
}
|