@xfey/tutti 0.1.41 → 0.1.43

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.
Files changed (49) hide show
  1. package/dist/artifacts/candidate-validator.d.ts +39 -0
  2. package/dist/artifacts/candidate-validator.js +512 -0
  3. package/dist/artifacts/index.d.ts +3 -1
  4. package/dist/artifacts/index.js +3 -1
  5. package/dist/artifacts/manifest-contract.d.ts +30 -0
  6. package/dist/artifacts/manifest-contract.js +214 -0
  7. package/dist/artifacts/manifest.d.ts +3 -12
  8. package/dist/artifacts/manifest.js +29 -201
  9. package/dist/artifacts/preview-runtime.d.ts +29 -10
  10. package/dist/artifacts/preview-runtime.js +400 -155
  11. package/dist/artifacts/process-boundary.d.ts +33 -0
  12. package/dist/artifacts/process-boundary.js +134 -0
  13. package/dist/control-plane/types.d.ts +7 -2
  14. package/dist/prompt-templates/index.d.ts +1 -1
  15. package/dist/prompt-templates/index.js +1 -0
  16. package/dist/provider-usage/index.js +1 -0
  17. package/dist/run-pipeline/artifact-applicability.d.ts +49 -0
  18. package/dist/run-pipeline/artifact-applicability.js +281 -0
  19. package/dist/run-pipeline/openai.d.ts +6 -0
  20. package/dist/run-pipeline/openai.js +72 -2
  21. package/dist/run-pipeline/promotion-reconcile.d.ts +9 -1
  22. package/dist/run-pipeline/promotion-reconcile.js +65 -11
  23. package/dist/server-shell/http/host-tunnel.js +1 -1
  24. package/dist/server-shell/http/routes/project-api/artifacts-routes.js +63 -14
  25. package/dist/server-shell/http/routes/project-api/types.d.ts +2 -0
  26. package/dist/workspace-ops/index.d.ts +1 -1
  27. package/dist/workspace-ops/index.js +1 -1
  28. package/dist/workspace-ops/run-workspaces.d.ts +1 -0
  29. package/dist/workspace-ops/run-workspaces.js +27 -0
  30. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +28 -8
  31. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +34 -9
  32. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  33. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  34. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +5 -1
  35. package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
  36. package/node_modules/@tutti/shared/dist/utils/redaction/index.js +14 -0
  37. package/package.json +1 -1
  38. package/prompts/README.md +1 -0
  39. package/prompts/artifacts/README.md +7 -0
  40. package/prompts/artifacts/applicability.md +41 -0
  41. package/prompts/prompt-flow-map.md +29 -26
  42. package/prompts/runs/README.md +3 -3
  43. package/prompts/runs/task-continuation.md +36 -4
  44. package/prompts/runs/task-retry.md +42 -6
  45. package/prompts/runs/task-run.md +36 -4
  46. package/web/assets/index-CT68KthI.js +29 -0
  47. package/web/assets/{index-g3gtJZjU.css → index-Sqw_t67u.css} +1 -1
  48. package/web/index.html +2 -2
  49. package/web/assets/index-D9zL_JFd.js +0 -29
@@ -1,28 +1,37 @@
1
- import { spawn } from "node:child_process";
2
- import { tmpdir } from "node:os";
3
- import { resolve } from "node:path";
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
4
3
  import { redactAndTruncateText } from "@tutti/shared/utils";
5
4
  import { WorkspaceOpsError } from "../workspace-ops/errors.js";
6
5
  import { ARTIFACT_PREVIEW_BASE_PATH, readArtifactDeclaration, } from "./manifest.js";
6
+ import { allocateArtifactPreviewPort, ArtifactDiagnosticBuffer, cleanupArtifactProcessRuntime, createArtifactPreviewEnv, createArtifactProcessRuntime, spawnArtifactPreviewProcess, terminateArtifactProcessTree, } from "./process-boundary.js";
7
7
  import { rewriteArtifactPreviewResourceUrls } from "./static-preview.js";
8
+ export const ARTIFACT_PREVIEW_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024;
9
+ export const ARTIFACT_PREVIEW_RESPONSE_BODY_LIMIT_BYTES = 5 * 1024 * 1024;
8
10
  const DEFAULT_READY_TIMEOUT_MS = 20_000;
9
11
  const DEFAULT_READY_PROBE_INTERVAL_MS = 250;
12
+ const DEFAULT_READY_PROBE_REQUEST_TIMEOUT_MS = 2_000;
10
13
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
11
14
  const DEFAULT_IDLE_STOP_TTL_MS = 120_000;
12
- const MAX_DIAGNOSTIC_CHARS = 8_000;
13
- const MIN_DYNAMIC_PORT = 49152;
14
- const MAX_DYNAMIC_PORT = 65535;
15
- const SAFE_ENV_NAMES = new Set([
16
- "PATH",
17
- "LANG",
18
- "LC_ALL",
19
- "LC_CTYPE",
20
- "TMPDIR",
21
- "TEMP",
22
- "TMP",
15
+ const MAX_READY_REDIRECTS = 3;
16
+ const MAX_PORT_LAUNCH_ATTEMPTS = 2;
17
+ const ALLOWED_REQUEST_HEADERS = new Set([
18
+ "accept",
19
+ "accept-language",
20
+ "cache-control",
21
+ "content-type",
22
+ "if-match",
23
+ "if-modified-since",
24
+ "if-none-match",
25
+ "if-unmodified-since",
26
+ "pragma",
27
+ "range",
28
+ "sec-fetch-dest",
29
+ "sec-fetch-mode",
30
+ "sec-fetch-site",
23
31
  ]);
24
32
  const RESPONSE_HEADER_DENYLIST = new Set([
25
33
  "connection",
34
+ "content-encoding",
26
35
  "content-length",
27
36
  "keep-alive",
28
37
  "proxy-authenticate",
@@ -34,60 +43,22 @@ const RESPONSE_HEADER_DENYLIST = new Set([
34
43
  "upgrade",
35
44
  "www-authenticate",
36
45
  ]);
37
- function defaultAllocatePort() {
38
- const range = MAX_DYNAMIC_PORT - MIN_DYNAMIC_PORT + 1;
39
- return MIN_DYNAMIC_PORT + Math.floor(Math.random() * range);
40
- }
41
- function defaultSpawnProcess(plan) {
42
- const child = spawn(plan.command, plan.args, {
43
- cwd: plan.cwd,
44
- env: plan.env,
45
- shell: false,
46
- stdio: ["pipe", "pipe", "pipe"],
47
- });
48
- child.stdin.end();
49
- return child;
50
- }
51
- function createArtifactPreviewEnv(options) {
52
- const env = {};
53
- for (const name of SAFE_ENV_NAMES) {
54
- const value = options.sourceEnv[name];
55
- if (value !== undefined && value.trim() !== "") {
56
- env[name] = value;
57
- }
58
- }
59
- env.HOME = tmpdir();
60
- env.TMPDIR = env.TMPDIR ?? tmpdir();
61
- env.HOST = "127.0.0.1";
62
- env.PORT = String(options.port);
63
- env.TUTTI_ARTIFACT_PREVIEW = "1";
64
- env.npm_config_update_notifier = "false";
65
- env.npm_config_fund = "false";
66
- env.npm_config_audit = "false";
67
- return env;
68
- }
69
- function appendDiagnostic(existing, chunk) {
70
- const next = `${existing}${redactAndTruncateText(chunk.toString("utf8"), MAX_DIAGNOSTIC_CHARS)}`;
71
- if (next.length <= MAX_DIAGNOSTIC_CHARS) {
72
- return next;
73
- }
74
- return next.slice(next.length - MAX_DIAGNOSTIC_CHARS);
46
+ const SENSITIVE_RESPONSE_HEADER_PATTERN = /authorization|cookie|credential|proxy-auth|secret|token|x-api[-_]?key/iu;
47
+ function isServerDeclaration(declaration) {
48
+ return declaration.status === "declared" && declaration.runtime.kind === "server";
75
49
  }
76
50
  function serverDeclarationKey(declaration) {
77
51
  return [
78
52
  declaration.manifest_blob_oid,
79
53
  declaration.runtime.cwd_path,
80
- declaration.runtime.ready_path,
54
+ declaration.runtime.entry_path,
81
55
  ].join(":");
82
56
  }
83
57
  function buildServerPreviewUrl(options) {
84
58
  const basePath = (options.basePath ?? ARTIFACT_PREVIEW_BASE_PATH).replace(/\/+$/u, "");
85
- const normalizedReadyPath = options.readyPath === "/" ? "/" : `/${options.readyPath.replace(/^\/+/u, "")}`;
86
- const separator = normalizedReadyPath.includes("?") ? "&" : "?";
87
- return `${basePath}${normalizedReadyPath}${separator}v=${encodeURIComponent(options.manifestBlobOid)}&preview=${encodeURIComponent(options.instanceId)}`;
88
- }
89
- function isServerDeclaration(declaration) {
90
- return declaration.status === "declared" && declaration.runtime.kind === "server";
59
+ const normalizedEntryPath = options.entryPath === "/" ? "/" : `/${options.entryPath.replace(/^\/+/, "")}`;
60
+ const separator = normalizedEntryPath.includes("?") ? "&" : "?";
61
+ return `${basePath}${normalizedEntryPath}${separator}v=${encodeURIComponent(options.manifestBlobOid)}&preview=${encodeURIComponent(options.instanceId)}`;
91
62
  }
92
63
  function declarationInvalidDisposition(declaration) {
93
64
  const disposition = {
@@ -109,7 +80,7 @@ function projectionWithServerState(options) {
109
80
  artifact: {
110
81
  title: artifact.title,
111
82
  kind: "server",
112
- network: artifact.network ?? false,
83
+ network: artifact.network,
113
84
  },
114
85
  preview: {
115
86
  status: "stopped",
@@ -126,7 +97,7 @@ function projectionWithServerState(options) {
126
97
  status: "ready",
127
98
  url: buildServerPreviewUrl({
128
99
  basePath: options.previewBasePath,
129
- readyPath: state.readyPath,
100
+ entryPath: state.entryPath,
130
101
  manifestBlobOid: state.manifestBlobOid,
131
102
  instanceId: state.instanceId,
132
103
  }),
@@ -141,15 +112,22 @@ function projectionWithServerState(options) {
141
112
  },
142
113
  };
143
114
  }
144
- function filteredResponseHeaders(headers) {
145
- const result = {};
146
- headers.forEach((value, key) => {
147
- const normalized = key.toLowerCase();
148
- if (!RESPONSE_HEADER_DENYLIST.has(normalized)) {
149
- result[normalized] = value;
115
+ function isContainedPath(root, target) {
116
+ const relativePath = relative(root, target);
117
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
118
+ }
119
+ function resolveServerCwd(workspaceRoot, cwdPath) {
120
+ try {
121
+ const repoRealPath = realpathSync.native(workspaceRoot);
122
+ const cwdRealPath = realpathSync.native(resolve(repoRealPath, cwdPath));
123
+ if (!isContainedPath(repoRealPath, cwdRealPath) || !lstatSync(cwdRealPath).isDirectory()) {
124
+ throw new Error("Server artifact cwd is not a contained directory");
150
125
  }
151
- });
152
- return result;
126
+ return cwdRealPath;
127
+ }
128
+ catch {
129
+ throw new WorkspaceOpsError("validation_failed", "Server artifact cwd is not usable");
130
+ }
153
131
  }
154
132
  function normalizePreviewRequestPath(path) {
155
133
  if (path === undefined || path.trim() === "") {
@@ -157,40 +135,151 @@ function normalizePreviewRequestPath(path) {
157
135
  }
158
136
  const withoutQuery = path.split("?")[0] ?? "";
159
137
  const normalized = withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
160
- if (normalized.includes("..")) {
138
+ let decoded;
139
+ try {
140
+ decoded = decodeURIComponent(normalized);
141
+ }
142
+ catch {
143
+ throw new WorkspaceOpsError("bad_request", "Artifact preview path is invalid");
144
+ }
145
+ if (decoded.includes("\\") ||
146
+ decoded.includes("\0") ||
147
+ decoded.split("/").some((segment) => segment === "." || segment === "..")) {
161
148
  throw new WorkspaceOpsError("forbidden", "Artifact preview path is not allowed");
162
149
  }
163
150
  return normalized;
164
151
  }
152
+ function responseLooksLikeHtml(content) {
153
+ const prefix = content.subarray(0, 1024).toString("utf8").trimStart().toLowerCase();
154
+ return prefix.startsWith("<!doctype html") || prefix.startsWith("<html");
155
+ }
156
+ async function readBoundedResponseBody(response, maxBytes) {
157
+ const contentLength = Number(response.headers.get("content-length"));
158
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
159
+ throw new WorkspaceOpsError("validation_failed", "Artifact preview response is too large");
160
+ }
161
+ if (response.body === null) {
162
+ return Buffer.alloc(0);
163
+ }
164
+ const reader = response.body.getReader();
165
+ const chunks = [];
166
+ let total = 0;
167
+ try {
168
+ while (true) {
169
+ const result = await reader.read();
170
+ if (result.done) {
171
+ break;
172
+ }
173
+ const value = result.value;
174
+ if (!(value instanceof Uint8Array)) {
175
+ throw new WorkspaceOpsError("validation_failed", "Artifact preview response body is invalid");
176
+ }
177
+ total += value.byteLength;
178
+ if (total > maxBytes) {
179
+ await reader.cancel();
180
+ throw new WorkspaceOpsError("validation_failed", "Artifact preview response is too large");
181
+ }
182
+ chunks.push(value);
183
+ }
184
+ }
185
+ finally {
186
+ reader.releaseLock();
187
+ }
188
+ return Buffer.concat(chunks, total);
189
+ }
190
+ function filteredResponseHeaders(headers) {
191
+ const result = {};
192
+ headers.forEach((value, key) => {
193
+ const normalized = key.toLowerCase();
194
+ if (!RESPONSE_HEADER_DENYLIST.has(normalized) &&
195
+ !SENSITIVE_RESPONSE_HEADER_PATTERN.test(normalized) &&
196
+ normalized !== "location") {
197
+ result[normalized] = value;
198
+ }
199
+ });
200
+ return result;
201
+ }
202
+ function sanitizeRequestHeaders(headers) {
203
+ const result = {};
204
+ for (const [name, value] of Object.entries(headers ?? {})) {
205
+ const normalized = name.toLowerCase();
206
+ if (value !== undefined && ALLOWED_REQUEST_HEADERS.has(normalized)) {
207
+ result[normalized] = value;
208
+ }
209
+ }
210
+ return result;
211
+ }
212
+ function safeRedirectLocation(options) {
213
+ const redirected = new URL(options.location, options.localUrl);
214
+ if (redirected.origin !== options.localUrl.origin) {
215
+ throw new WorkspaceOpsError("validation_failed", "Artifact preview redirect left the managed preview instance");
216
+ }
217
+ redirected.searchParams.set("v", options.state.manifestBlobOid);
218
+ redirected.searchParams.set("preview", options.state.instanceId);
219
+ return `${ARTIFACT_PREVIEW_BASE_PATH}${redirected.pathname}${redirected.search}${redirected.hash}`;
220
+ }
221
+ function unrefTimer(timer) {
222
+ if (typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") {
223
+ timer.unref();
224
+ }
225
+ }
226
+ function processIsAlive(child) {
227
+ return child !== undefined && child.exitCode === null && child.signalCode === null;
228
+ }
229
+ export function isArtifactPreviewHttpMethod(method) {
230
+ return (method === "GET" ||
231
+ method === "HEAD" ||
232
+ method === "POST" ||
233
+ method === "PUT" ||
234
+ method === "PATCH" ||
235
+ method === "DELETE" ||
236
+ method === "OPTIONS");
237
+ }
165
238
  export class ArtifactPreviewManager {
166
239
  now;
167
240
  baseEnv;
168
241
  spawnProcess;
242
+ terminateProcess;
169
243
  fetchImpl;
170
244
  allocatePort;
245
+ createProcessRuntime;
246
+ cleanupProcessRuntime;
171
247
  clockMs;
172
248
  setTimeoutFn;
173
249
  clearTimeoutFn;
174
250
  onStatusChange;
175
251
  readyTimeoutMs;
176
252
  readyProbeIntervalMs;
253
+ readyProbeRequestTimeoutMs;
177
254
  requestTimeoutMs;
255
+ requestBodyLimitBytes;
256
+ responseBodyLimitBytes;
178
257
  idleStopTtlMs;
258
+ pendingTasks = new Set();
179
259
  state;
180
260
  nextInstanceSequence = 0;
181
261
  constructor(options = {}) {
182
262
  this.now = options.now ?? (() => new Date());
183
263
  this.baseEnv = options.baseEnv ?? process.env;
184
- this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
264
+ this.spawnProcess = options.spawnProcess ?? spawnArtifactPreviewProcess;
265
+ this.terminateProcess = options.terminateProcess ?? terminateArtifactProcessTree;
185
266
  this.fetchImpl = options.fetchImpl ?? fetch;
186
- this.allocatePort = options.allocatePort ?? defaultAllocatePort;
267
+ this.allocatePort = options.allocatePort ?? allocateArtifactPreviewPort;
268
+ this.createProcessRuntime = options.createProcessRuntime ?? createArtifactProcessRuntime;
269
+ this.cleanupProcessRuntime = options.cleanupProcessRuntime ?? cleanupArtifactProcessRuntime;
187
270
  this.clockMs = options.clockMs ?? (() => Date.now());
188
271
  this.setTimeoutFn = options.setTimeoutFn ?? setTimeout;
189
272
  this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
190
273
  this.onStatusChange = options.onStatusChange ?? (() => { });
191
274
  this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
192
275
  this.readyProbeIntervalMs = options.readyProbeIntervalMs ?? DEFAULT_READY_PROBE_INTERVAL_MS;
276
+ this.readyProbeRequestTimeoutMs =
277
+ options.readyProbeRequestTimeoutMs ?? DEFAULT_READY_PROBE_REQUEST_TIMEOUT_MS;
193
278
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
279
+ this.requestBodyLimitBytes =
280
+ options.requestBodyLimitBytes ?? ARTIFACT_PREVIEW_REQUEST_BODY_LIMIT_BYTES;
281
+ this.responseBodyLimitBytes =
282
+ options.responseBodyLimitBytes ?? ARTIFACT_PREVIEW_RESPONSE_BODY_LIMIT_BYTES;
194
283
  this.idleStopTtlMs = options.idleStopTtlMs ?? DEFAULT_IDLE_STOP_TTL_MS;
195
284
  }
196
285
  readProjection(options) {
@@ -296,7 +385,8 @@ export class ArtifactPreviewManager {
296
385
  result: { artifact: declaration.projection },
297
386
  };
298
387
  }
299
- if (!isServerDeclaration(declaration) || this.state?.key !== serverDeclarationKey(declaration)) {
388
+ if (!isServerDeclaration(declaration) ||
389
+ this.state?.key !== serverDeclarationKey(declaration)) {
300
390
  return {
301
391
  disposition: { kind: "not_running" },
302
392
  result: {
@@ -354,7 +444,8 @@ export class ArtifactPreviewManager {
354
444
  result: { artifact: declaration.projection },
355
445
  };
356
446
  }
357
- if (!isServerDeclaration(declaration) || this.state?.key !== serverDeclarationKey(declaration)) {
447
+ if (!isServerDeclaration(declaration) ||
448
+ this.state?.key !== serverDeclarationKey(declaration)) {
358
449
  return {
359
450
  disposition: { kind: "not_running" },
360
451
  result: { artifact: declaration.projection },
@@ -374,50 +465,87 @@ export class ArtifactPreviewManager {
374
465
  };
375
466
  }
376
467
  async fetchServerPreview(options) {
468
+ if (options.body !== undefined && options.body.byteLength > this.requestBodyLimitBytes) {
469
+ throw new WorkspaceOpsError("validation_failed", "Artifact preview request is too large");
470
+ }
471
+ if ((options.method === "GET" || options.method === "HEAD") && options.body !== undefined) {
472
+ throw new WorkspaceOpsError("bad_request", "Artifact preview request method cannot have a body");
473
+ }
377
474
  const declaration = readArtifactDeclaration({
378
475
  workspaceRoot: options.workspaceRoot,
379
476
  now: this.now,
380
477
  });
381
478
  this.stopStaleRuntime(declaration);
382
- if (!isServerDeclaration(declaration) || this.state?.key !== serverDeclarationKey(declaration)) {
479
+ const state = this.state;
480
+ if (!isServerDeclaration(declaration) || state?.key !== serverDeclarationKey(declaration)) {
383
481
  throw new WorkspaceOpsError("not_found", "Artifact preview is not running");
384
482
  }
385
- if (this.state.status !== "ready") {
483
+ if (state.status !== "ready" || state.port === undefined || !processIsAlive(state.child)) {
484
+ if (state.status === "ready") {
485
+ this.failState(state, "Preview process is no longer running");
486
+ }
386
487
  throw new WorkspaceOpsError("validation_failed", "Artifact preview is not ready");
387
488
  }
388
489
  const requestPath = normalizePreviewRequestPath(options.path);
389
- const localUrl = new URL(`http://127.0.0.1:${this.state.port}${requestPath}`);
390
- const query = options.query?.replace(/^\?/u, "");
391
- if (query !== undefined && query.length > 0) {
392
- localUrl.search = query;
393
- localUrl.searchParams.delete("v");
490
+ const localUrl = new URL(`http://127.0.0.1:${state.port}${requestPath}`);
491
+ const query = new URLSearchParams(options.query?.replace(/^\?/u, "") ?? "");
492
+ const requestedManifestBlobOid = query.get("v");
493
+ if (requestedManifestBlobOid !== null && requestedManifestBlobOid !== state.manifestBlobOid) {
494
+ throw new WorkspaceOpsError("not_found", "Artifact preview manifest is stale");
495
+ }
496
+ const requestedInstanceId = query.get("preview");
497
+ if (requestedInstanceId !== null && requestedInstanceId !== state.instanceId) {
498
+ throw new WorkspaceOpsError("not_found", "Artifact preview instance is stale");
499
+ }
500
+ query.delete("v");
501
+ query.delete("preview");
502
+ localUrl.search = query.toString();
503
+ const requestHeaders = sanitizeRequestHeaders(options.headers);
504
+ if (options.headers?.origin !== undefined) {
505
+ requestHeaders.origin = localUrl.origin;
394
506
  }
395
507
  const controller = new AbortController();
396
508
  const timeout = this.setTimeoutFn(() => controller.abort(), this.requestTimeoutMs);
509
+ unrefTimer(timeout);
397
510
  try {
398
511
  const response = await this.fetchImpl(localUrl, {
399
- method: "GET",
400
- headers: {
401
- ...(options.headers?.accept === undefined ? {} : { accept: options.headers.accept }),
402
- ...(options.headers?.["accept-language"] === undefined
403
- ? {}
404
- : { "accept-language": options.headers["accept-language"] }),
405
- },
512
+ method: options.method,
513
+ headers: requestHeaders,
514
+ ...(options.body === undefined ? {} : { body: options.body }),
406
515
  redirect: "manual",
407
516
  signal: controller.signal,
408
517
  });
518
+ const content = await readBoundedResponseBody(response, this.responseBodyLimitBytes);
519
+ if (this.state !== state ||
520
+ state.status !== "ready" ||
521
+ state.port === undefined ||
522
+ !processIsAlive(state.child)) {
523
+ throw new WorkspaceOpsError("not_found", "Artifact preview instance is stale");
524
+ }
409
525
  const headers = filteredResponseHeaders(response.headers);
410
- const content = rewriteArtifactPreviewResourceUrls({
526
+ const location = response.headers.get("location");
527
+ if (location !== null) {
528
+ headers.location = safeRedirectLocation({ location, localUrl, state });
529
+ }
530
+ const rewrittenContent = rewriteArtifactPreviewResourceUrls({
411
531
  mediaType: headers["content-type"],
412
532
  resourcePath: requestPath,
413
- content: Buffer.from(await response.arrayBuffer()),
533
+ content,
414
534
  });
415
535
  return {
416
536
  status_code: response.status,
417
537
  headers,
418
- content,
538
+ content: rewrittenContent,
419
539
  };
420
540
  }
541
+ catch (error) {
542
+ if (error instanceof WorkspaceOpsError) {
543
+ throw error;
544
+ }
545
+ throw new WorkspaceOpsError("validation_failed", controller.signal.aborted
546
+ ? "Artifact preview request timed out"
547
+ : "Artifact preview request failed");
548
+ }
421
549
  finally {
422
550
  this.clearTimeoutFn(timeout);
423
551
  }
@@ -432,54 +560,91 @@ export class ArtifactPreviewManager {
432
560
  }
433
561
  this.stopCurrentProcess();
434
562
  }
435
- stopAll() {
563
+ async stopAll() {
436
564
  this.stopCurrentProcess();
565
+ while (this.pendingTasks.size > 0) {
566
+ await Promise.all([...this.pendingTasks]);
567
+ }
437
568
  }
438
569
  startProcess(workspaceRoot, declaration) {
439
- const port = this.allocatePort();
440
- const cwd = resolve(workspaceRoot, declaration.runtime.cwd_path);
441
- const plan = {
442
- command: "npm",
443
- args: ["run", "artifact:preview"],
444
- cwd,
445
- env: createArtifactPreviewEnv({
446
- sourceEnv: this.baseEnv,
447
- port,
448
- }),
449
- port,
450
- };
451
- const child = this.spawnProcess(plan);
452
570
  const state = {
453
571
  key: serverDeclarationKey(declaration),
454
572
  manifestBlobOid: declaration.manifest_blob_oid,
455
- readyPath: declaration.runtime.ready_path,
456
- port,
573
+ entryPath: declaration.runtime.entry_path,
574
+ cwdPath: resolveServerCwd(workspaceRoot, declaration.runtime.cwd_path),
457
575
  status: "starting",
458
- child,
576
+ child: undefined,
577
+ processRuntime: undefined,
578
+ port: undefined,
459
579
  instanceId: this.nextInstanceId(),
580
+ launchAttempt: 0,
460
581
  lastHeartbeatAtMs: this.clockMs(),
461
582
  errorMessage: undefined,
462
- diagnostics: "",
583
+ diagnostics: new ArtifactDiagnosticBuffer(),
463
584
  idleTimer: undefined,
464
585
  };
465
586
  this.state = state;
466
587
  this.scheduleIdleSweep(state);
467
- this.attachProcessListeners(state, child);
468
588
  this.onStatusChange();
469
- void this.waitUntilReady(state);
589
+ this.trackTask(this.launchProcess(state));
470
590
  }
471
- attachProcessListeners(state, child) {
472
- child.stdout.on("data", (chunk) => {
473
- state.diagnostics = appendDiagnostic(state.diagnostics, chunk);
474
- });
475
- child.stderr.on("data", (chunk) => {
476
- state.diagnostics = appendDiagnostic(state.diagnostics, chunk);
477
- });
591
+ async launchProcess(state) {
592
+ if (this.state !== state || state.status !== "starting") {
593
+ return;
594
+ }
595
+ state.launchAttempt += 1;
596
+ const runtime = this.createProcessRuntime();
597
+ state.processRuntime = runtime;
598
+ try {
599
+ const port = await this.allocatePort();
600
+ if (this.state !== state || state.status !== "starting") {
601
+ this.cleanupStateRuntime(state, runtime);
602
+ return;
603
+ }
604
+ const plan = {
605
+ command: "npm",
606
+ args: ["run", "artifact:preview"],
607
+ cwd: state.cwdPath,
608
+ env: createArtifactPreviewEnv({
609
+ sourceEnv: this.baseEnv,
610
+ port,
611
+ runtime,
612
+ }),
613
+ port,
614
+ };
615
+ const child = this.spawnProcess(plan);
616
+ state.port = port;
617
+ state.child = child;
618
+ this.attachProcessListeners(state, child, runtime);
619
+ await this.waitUntilReady(state);
620
+ }
621
+ catch (error) {
622
+ this.cleanupStateRuntime(state, runtime);
623
+ this.failState(state, redactAndTruncateText(error instanceof Error ? error.message : "Preview process could not start", 220));
624
+ }
625
+ }
626
+ attachProcessListeners(state, child, runtime) {
627
+ child.stdout.on("data", (chunk) => state.diagnostics.append("stdout", chunk));
628
+ child.stderr.on("data", (chunk) => state.diagnostics.append("stderr", chunk));
478
629
  child.on("error", (error) => {
479
- this.failState(state, redactAndTruncateText(error.message, 220));
630
+ if (state.child === child) {
631
+ this.failState(state, redactAndTruncateText(error.message, 220));
632
+ }
480
633
  });
481
634
  child.on("close", (code, signal) => {
482
- if (this.state !== state || state.status === "stopped") {
635
+ if (this.state !== state || state.child !== child || state.status === "stopped") {
636
+ this.cleanupStateRuntime(state, runtime);
637
+ return;
638
+ }
639
+ state.child = undefined;
640
+ state.port = undefined;
641
+ this.cleanupStateRuntime(state, runtime);
642
+ const diagnostic = state.diagnostics.read();
643
+ if (state.status === "starting" &&
644
+ state.launchAttempt < MAX_PORT_LAUNCH_ATTEMPTS &&
645
+ /EADDRINUSE/iu.test(diagnostic.text)) {
646
+ state.diagnostics = new ArtifactDiagnosticBuffer();
647
+ this.trackTask(this.launchProcess(state));
483
648
  return;
484
649
  }
485
650
  const reason = code === null ? `signal ${signal ?? "unknown"}` : `exit code ${code}`;
@@ -488,38 +653,98 @@ export class ArtifactPreviewManager {
488
653
  }
489
654
  async waitUntilReady(state) {
490
655
  const deadline = this.clockMs() + this.readyTimeoutMs;
491
- const readyUrl = new URL(`http://127.0.0.1:${state.port}${state.readyPath}`);
492
656
  while (this.state === state && state.status === "starting" && this.clockMs() < deadline) {
493
- try {
494
- const controller = new AbortController();
495
- const timeout = this.setTimeoutFn(() => controller.abort(), this.readyProbeIntervalMs);
496
- try {
497
- const response = await this.fetchImpl(readyUrl, {
498
- method: "GET",
499
- signal: controller.signal,
500
- });
501
- if (response.status >= 200 && response.status < 500) {
502
- state.status = "ready";
503
- state.errorMessage = undefined;
504
- this.onStatusChange();
505
- return;
506
- }
507
- }
508
- finally {
509
- this.clearTimeoutFn(timeout);
510
- }
657
+ if (state.port === undefined || !processIsAlive(state.child)) {
658
+ return;
511
659
  }
512
- catch {
513
- // Keep probing until timeout or process exit.
660
+ const probe = await this.probeReadyEntry(state);
661
+ if (this.state !== state || state.status !== "starting") {
662
+ return;
663
+ }
664
+ if (probe.kind === "ready") {
665
+ state.status = "ready";
666
+ state.errorMessage = undefined;
667
+ this.onStatusChange();
668
+ return;
669
+ }
670
+ if (probe.kind === "invalid") {
671
+ this.failState(state, probe.message);
672
+ return;
514
673
  }
515
674
  await new Promise((resolveDelay) => {
516
- this.setTimeoutFn(resolveDelay, this.readyProbeIntervalMs);
675
+ const timer = this.setTimeoutFn(resolveDelay, this.readyProbeIntervalMs);
676
+ unrefTimer(timer);
517
677
  });
518
678
  }
519
679
  if (this.state === state && state.status === "starting") {
520
680
  this.failState(state, "Preview did not become ready in time");
521
681
  }
522
682
  }
683
+ async probeReadyEntry(state) {
684
+ if (state.port === undefined) {
685
+ return { kind: "retry" };
686
+ }
687
+ const entryOrigin = `http://127.0.0.1:${state.port}`;
688
+ let current = new URL(state.entryPath, entryOrigin);
689
+ for (let redirects = 0; redirects <= MAX_READY_REDIRECTS; redirects += 1) {
690
+ const controller = new AbortController();
691
+ const timeout = this.setTimeoutFn(() => controller.abort(), this.readyProbeRequestTimeoutMs);
692
+ unrefTimer(timeout);
693
+ let response;
694
+ try {
695
+ response = await this.fetchImpl(current, {
696
+ method: "GET",
697
+ redirect: "manual",
698
+ signal: controller.signal,
699
+ });
700
+ }
701
+ catch {
702
+ return { kind: "retry" };
703
+ }
704
+ finally {
705
+ this.clearTimeoutFn(timeout);
706
+ }
707
+ if (response.status >= 300 && response.status < 400) {
708
+ const location = response.headers.get("location");
709
+ if (location === null || redirects === MAX_READY_REDIRECTS) {
710
+ return { kind: "invalid", message: "Preview entry returned an invalid redirect" };
711
+ }
712
+ const redirected = new URL(location, current);
713
+ if (redirected.origin !== entryOrigin) {
714
+ return {
715
+ kind: "invalid",
716
+ message: "Preview entry redirected outside its managed instance",
717
+ };
718
+ }
719
+ current = redirected;
720
+ continue;
721
+ }
722
+ if (response.status < 200 || response.status >= 300) {
723
+ return {
724
+ kind: "invalid",
725
+ message: `Preview entry returned HTTP ${response.status}`,
726
+ };
727
+ }
728
+ try {
729
+ const content = await readBoundedResponseBody(response, this.responseBodyLimitBytes);
730
+ const mediaType = response.headers.get("content-type")?.toLowerCase() ?? "";
731
+ if (!mediaType.includes("text/html") && !responseLooksLikeHtml(content)) {
732
+ return {
733
+ kind: "invalid",
734
+ message: "Preview entry did not return renderable HTML",
735
+ };
736
+ }
737
+ }
738
+ catch (error) {
739
+ return {
740
+ kind: "invalid",
741
+ message: error instanceof Error ? error.message : "Preview entry response is invalid",
742
+ };
743
+ }
744
+ return { kind: "ready" };
745
+ }
746
+ return { kind: "invalid", message: "Preview entry redirected too many times" };
747
+ }
523
748
  recordHeartbeat(state) {
524
749
  state.lastHeartbeatAtMs = this.clockMs();
525
750
  this.scheduleIdleSweep(state);
@@ -529,6 +754,7 @@ export class ArtifactPreviewManager {
529
754
  this.clearTimeoutFn(state.idleTimer);
530
755
  }
531
756
  state.idleTimer = this.setTimeoutFn(() => this.sweepIdle(), this.idleStopTtlMs);
757
+ unrefTimer(state.idleTimer);
532
758
  }
533
759
  stopStaleRuntime(declaration) {
534
760
  if (this.state === undefined) {
@@ -548,12 +774,7 @@ export class ArtifactPreviewManager {
548
774
  this.clearTimeoutFn(state.idleTimer);
549
775
  state.idleTimer = undefined;
550
776
  }
551
- if (state.child !== undefined &&
552
- state.child.exitCode === null &&
553
- state.child.signalCode === null) {
554
- state.child.kill("SIGTERM");
555
- }
556
- state.child = undefined;
777
+ this.disposeStateProcess(state);
557
778
  state.status = "stopped";
558
779
  state.errorMessage = undefined;
559
780
  this.onStatusChange();
@@ -563,19 +784,43 @@ export class ArtifactPreviewManager {
563
784
  return;
564
785
  }
565
786
  state.status = "failed";
566
- state.errorMessage = message;
567
- if (state.child !== undefined &&
568
- state.child.exitCode === null &&
569
- state.child.signalCode === null) {
570
- state.child.kill("SIGTERM");
571
- }
572
- state.child = undefined;
787
+ state.errorMessage = redactAndTruncateText(message, 220);
573
788
  if (state.idleTimer !== undefined) {
574
789
  this.clearTimeoutFn(state.idleTimer);
575
790
  state.idleTimer = undefined;
576
791
  }
792
+ this.disposeStateProcess(state);
577
793
  this.onStatusChange();
578
794
  }
795
+ disposeStateProcess(state) {
796
+ const child = state.child;
797
+ const runtime = state.processRuntime;
798
+ state.child = undefined;
799
+ state.processRuntime = undefined;
800
+ state.port = undefined;
801
+ if (child === undefined) {
802
+ if (runtime !== undefined) {
803
+ this.cleanupProcessRuntime(runtime);
804
+ }
805
+ return;
806
+ }
807
+ this.trackTask(this.terminateProcess(child).finally(() => {
808
+ if (runtime !== undefined) {
809
+ this.cleanupProcessRuntime(runtime);
810
+ }
811
+ }));
812
+ }
813
+ cleanupStateRuntime(state, runtime) {
814
+ if (state.processRuntime !== runtime) {
815
+ return;
816
+ }
817
+ state.processRuntime = undefined;
818
+ this.cleanupProcessRuntime(runtime);
819
+ }
820
+ trackTask(task) {
821
+ this.pendingTasks.add(task);
822
+ void task.finally(() => this.pendingTasks.delete(task));
823
+ }
579
824
  nextInstanceId() {
580
825
  this.nextInstanceSequence += 1;
581
826
  return `artifact_preview_${this.nextInstanceSequence}`;