@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.
@@ -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 = [root, ...allowedOutputRoots.map(outputRoot => path.resolve(root, outputRoot))];
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 = [mapped, ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map(ext => path.join(dir, `${name}${ext}`))];
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) || (targetStat.ino !== 0 && fileStat?.ino === targetStat.ino && fileStat.dev === targetStat.dev)) {
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 && (!Array.isArray(targetFieldPath)
75
- || !targetFieldPath.length || targetFieldPath.some(key => typeof key !== 'string' || !key.length))) {
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 (themeMatch && existsSync(schemaFile)) {
102
+ if (themeId && existsSync(schemaFile)) {
87
103
  const schema = JSON5.parse(readFileSync(projectFile(root, THEME_SCHEMA_PATH), 'utf8'));
88
- const dataPath = themeDataPath(themeMatch[1]);
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, [candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay', ...candidate.path.slice(1)]);
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/') ? path.resolve(root, value.slice('/project/'.length))
101
- : path.resolve(themeRoot, value);
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 && candidateStat.ino === targetStat.ino && candidateStat.dev === targetStat.dev;
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 && !fields.some(candidate => candidate.path.length === targetFieldPath.length
120
- && candidate.path.every((key, index) => key === targetFieldPath[index]))) {
121
- throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map(candidate => JSON.stringify(candidate.path)).join(', ')}.`);
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.filter(ref => typeof ref === 'string' && ref.trim()).map(ref => {
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.replace(/^\.\/+/, '').replace(/^\/+/, '').replace(/\/{2,}/g, '/');
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 (!themeMatch || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
170
+ if (!themeId || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
140
171
  return raw;
141
- return path.posix.join('src/theme', themeMatch[1], normalized);
142
- }) : [];
143
- const references = options.referenceImagePaths?.length ? options.referenceImagePaths
144
- : options.useTargetReference === false ? [] : schemaReferences.length ? schemaReferences : [relativeTarget];
145
- const referenceImagePaths = references.map(ref => /^https?:\/\//i.test(ref) ? ref
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 = !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
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, height: delivery.height,
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' : delivery.format),
217
+ ? 'png'
218
+ : delivery.format),
178
219
  outputFormatFromSchema: delivery.fromSchemaMimeType,
179
- fitMode: delivery.fitMode, removeBackground: delivery.removeBackground, paddingColor: delivery.paddingColor,
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
- && field?.path.every((key, index) => key === targetFieldPath?.[index]);
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 ? contexts.find(candidate => candidate.fieldPath?.length === targetFieldPath.length
200
- && candidate.fieldPath.every((key, index) => key === targetFieldPath[index])) : contexts[0];
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({ ...delivery, aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio });
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 || contexts.some(candidate => deliveryKey(candidate) !== deliveryKey(context));
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 || contexts.some(candidate => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema)
212
- || (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
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
- await writeFile(// shared
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
+ }