@xfey/tutti 0.1.42 → 0.1.44
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/artifacts/candidate-validator.d.ts +39 -0
- package/dist/artifacts/candidate-validator.js +512 -0
- package/dist/artifacts/index.d.ts +3 -1
- package/dist/artifacts/index.js +3 -1
- package/dist/artifacts/manifest-contract.d.ts +30 -0
- package/dist/artifacts/manifest-contract.js +214 -0
- package/dist/artifacts/manifest.d.ts +3 -12
- package/dist/artifacts/manifest.js +29 -201
- package/dist/artifacts/preview-runtime.d.ts +29 -10
- package/dist/artifacts/preview-runtime.js +400 -155
- package/dist/artifacts/process-boundary.d.ts +33 -0
- package/dist/artifacts/process-boundary.js +134 -0
- package/dist/control-plane/types.d.ts +7 -2
- package/dist/prompt-templates/index.d.ts +1 -1
- package/dist/prompt-templates/index.js +1 -0
- package/dist/provider-usage/index.js +1 -0
- package/dist/run-pipeline/artifact-applicability.d.ts +49 -0
- package/dist/run-pipeline/artifact-applicability.js +281 -0
- package/dist/run-pipeline/openai.d.ts +6 -0
- package/dist/run-pipeline/openai.js +72 -2
- package/dist/run-pipeline/promotion-reconcile.d.ts +9 -1
- package/dist/run-pipeline/promotion-reconcile.js +65 -11
- package/dist/server-shell/cli/launch.d.ts +2 -0
- package/dist/server-shell/cli/launch.js +7 -1
- package/dist/server-shell/http/host-tunnel.js +1 -1
- package/dist/server-shell/http/routes/project-api/artifacts-routes.js +63 -14
- package/dist/server-shell/http/routes/project-api/types.d.ts +2 -0
- package/dist/server-shell/local-console/project-service.d.ts +5 -0
- package/dist/server-shell/local-console/project-service.js +25 -0
- package/dist/server-shell/local-console/server.js +10 -0
- package/dist/workspace-ops/index.d.ts +1 -1
- package/dist/workspace-ops/index.js +1 -1
- package/dist/workspace-ops/run-workspaces.d.ts +1 -0
- package/dist/workspace-ops/run-workspaces.js +27 -0
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +28 -8
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +34 -9
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +5 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.js +14 -0
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/artifacts/README.md +7 -0
- package/prompts/artifacts/applicability.md +41 -0
- package/prompts/prompt-flow-map.md +29 -26
- package/prompts/runs/README.md +3 -3
- package/prompts/runs/task-continuation.md +36 -4
- package/prompts/runs/task-retry.md +42 -6
- package/prompts/runs/task-run.md +36 -4
- package/web/assets/index-B7I1QwT_.js +69 -0
- package/web/assets/index-F-ouhuJ5.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-Sqw_t67u.css +0 -1
- package/web/assets/index-cYUjFgtc.js +0 -29
|
@@ -1,28 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
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.
|
|
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
|
|
86
|
-
const separator =
|
|
87
|
-
return `${basePath}${
|
|
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
|
|
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
|
-
|
|
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
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
264
|
+
this.spawnProcess = options.spawnProcess ?? spawnArtifactPreviewProcess;
|
|
265
|
+
this.terminateProcess = options.terminateProcess ?? terminateArtifactProcessTree;
|
|
185
266
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
186
|
-
this.allocatePort = options.allocatePort ??
|
|
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) ||
|
|
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) ||
|
|
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
|
-
|
|
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 (
|
|
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:${
|
|
390
|
-
const query = options.query?.replace(/^\?/u, "");
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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:
|
|
400
|
-
headers:
|
|
401
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
589
|
+
this.trackTask(this.launchProcess(state));
|
|
470
590
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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}`;
|