@xfey/tutti 0.1.53 → 0.1.55

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 (63) hide show
  1. package/README.md +5 -2
  2. package/desktop-assets/README.md +13 -0
  3. package/desktop-assets/Tutti-square.png +0 -0
  4. package/desktop-assets/Tutti.icns +0 -0
  5. package/desktop-assets/macos-handler.applescript +24 -0
  6. package/dist/artifacts/index.d.ts +3 -3
  7. package/dist/artifacts/index.js +3 -3
  8. package/dist/artifacts/live-manifest.d.ts +23 -0
  9. package/dist/artifacts/live-manifest.js +115 -0
  10. package/dist/artifacts/preview-runtime.d.ts +34 -36
  11. package/dist/artifacts/preview-runtime.js +215 -273
  12. package/dist/artifacts/static-preview.d.ts +2 -1
  13. package/dist/artifacts/static-preview.js +19 -21
  14. package/dist/control-plane/index.d.ts +1 -0
  15. package/dist/control-plane/index.js +13 -2
  16. package/dist/control-plane/types.d.ts +4 -0
  17. package/dist/server-shell/cli/args.d.ts +7 -0
  18. package/dist/server-shell/cli/args.js +32 -0
  19. package/dist/server-shell/cli/cli.js +76 -1
  20. package/dist/server-shell/cli/errors.d.ts +1 -1
  21. package/dist/server-shell/cli/host-server-runtime.js +20 -0
  22. package/dist/server-shell/cli/version.d.ts +6 -0
  23. package/dist/server-shell/cli/version.js +11 -2
  24. package/dist/server-shell/desktop-integration/index.d.ts +5 -0
  25. package/dist/server-shell/desktop-integration/index.js +5 -0
  26. package/dist/server-shell/desktop-integration/linux.d.ts +12 -0
  27. package/dist/server-shell/desktop-integration/linux.js +186 -0
  28. package/dist/server-shell/desktop-integration/macos.d.ts +9 -0
  29. package/dist/server-shell/desktop-integration/macos.js +138 -0
  30. package/dist/server-shell/desktop-integration/manager.d.ts +40 -0
  31. package/dist/server-shell/desktop-integration/manager.js +223 -0
  32. package/dist/server-shell/desktop-integration/platform.d.ts +23 -0
  33. package/dist/server-shell/desktop-integration/platform.js +21 -0
  34. package/dist/server-shell/desktop-integration/protocol.d.ts +9 -0
  35. package/dist/server-shell/desktop-integration/protocol.js +28 -0
  36. package/dist/server-shell/desktop-integration/record.d.ts +17 -0
  37. package/dist/server-shell/desktop-integration/record.js +74 -0
  38. package/dist/server-shell/http/routes/project-api/artifacts-routes.js +43 -110
  39. package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.d.ts +5 -8
  40. package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.js +5 -33
  41. package/dist/server-shell/http/routes/project-api/openapi.d.ts +17 -8
  42. package/dist/server-shell/http/routes/project-api/types.d.ts +3 -2
  43. package/dist/server-shell/local-console/invocation-context.d.ts +7 -0
  44. package/dist/server-shell/local-console/invocation-context.js +8 -9
  45. package/dist/server-shell/local-console/managed-console.d.ts +1 -0
  46. package/dist/server-shell/local-console/managed-console.js +10 -5
  47. package/dist/server-shell/local-console/project-service.js +1 -1
  48. package/dist/server-shell/local-console/server.js +23 -6
  49. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +7 -88
  50. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +3 -48
  51. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  52. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  53. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +1 -10
  54. package/package.json +2 -1
  55. package/web/assets/{homepage-motion-scene-F4ibPvI9.js → homepage-motion-scene-D0-wLq3D.js} +1 -1
  56. package/web/assets/{index-B1mBtL9x.js → index-BAsLSZ5U.js} +14 -14
  57. package/web/assets/index-DMaiDZ9k.css +1 -0
  58. package/web/assets/tutti_avatar-_0Yc-cMJ.png +0 -0
  59. package/web/index.html +2 -2
  60. package/dist/artifacts/manifest.d.ts +0 -41
  61. package/dist/artifacts/manifest.js +0 -169
  62. package/web/assets/index-OFlcQ3eS.css +0 -1
  63. package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
@@ -2,11 +2,12 @@ import { lstatSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { redactAndTruncateText } from "@tutti/shared/utils";
4
4
  import { WorkspaceOpsError } from "../workspace-ops/errors.js";
5
- import { ARTIFACT_PREVIEW_BASE_PATH, readArtifactDeclaration, } from "./manifest.js";
5
+ import { readLiveArtifactDeclaration, } from "./live-manifest.js";
6
6
  import { allocateArtifactPreviewPort, ArtifactDiagnosticBuffer, cleanupArtifactProcessRuntime, createArtifactPreviewEnv, createArtifactProcessRuntime, spawnArtifactPreviewProcess, terminateArtifactProcessTree, } from "./process-boundary.js";
7
- import { rewriteArtifactPreviewResourceUrls } from "./static-preview.js";
7
+ import { readArtifactStaticPreview, rewriteArtifactPreviewResourceUrls, } from "./static-preview.js";
8
8
  export const ARTIFACT_PREVIEW_REQUEST_BODY_LIMIT_BYTES = 1024 * 1024;
9
9
  export const ARTIFACT_PREVIEW_RESPONSE_BODY_LIMIT_BYTES = 5 * 1024 * 1024;
10
+ export const ARTIFACT_PREVIEW_BASE_PATH = "/artifacts/preview";
10
11
  const DEFAULT_READY_TIMEOUT_MS = 20_000;
11
12
  const DEFAULT_READY_PROBE_INTERVAL_MS = 250;
12
13
  const DEFAULT_READY_PROBE_REQUEST_TIMEOUT_MS = 2_000;
@@ -44,64 +45,54 @@ const RESPONSE_HEADER_DENYLIST = new Set([
44
45
  "www-authenticate",
45
46
  ]);
46
47
  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";
49
- }
50
- function serverDeclarationKey(declaration) {
51
- return [
52
- declaration.snapshot_oid,
53
- declaration.manifest_blob_oid,
54
- declaration.runtime.cwd_path,
55
- declaration.runtime.entry_path,
56
- ].join(":");
57
- }
58
48
  function buildServerPreviewUrl(options) {
59
49
  const basePath = (options.basePath ?? ARTIFACT_PREVIEW_BASE_PATH).replace(/\/+$/u, "");
60
50
  const normalizedEntryPath = options.entryPath === "/" ? "/" : `/${options.entryPath.replace(/^\/+/, "")}`;
61
51
  const separator = normalizedEntryPath.includes("?") ? "&" : "?";
62
- return `${basePath}${normalizedEntryPath}${separator}v=${encodeURIComponent(options.manifestBlobOid)}&preview=${encodeURIComponent(options.instanceId)}`;
52
+ return `${basePath}${normalizedEntryPath}${separator}g=${encodeURIComponent(options.generation)}`;
63
53
  }
64
- function declarationInvalidDisposition(declaration) {
65
- const disposition = {
66
- kind: "invalid_manifest",
67
- message: declaration.projection.error.message,
68
- };
69
- return {
70
- start: disposition,
71
- heartbeat: disposition,
72
- stop: disposition,
73
- };
54
+ function buildStaticPreviewUrl(options) {
55
+ const basePath = (options.basePath ?? ARTIFACT_PREVIEW_BASE_PATH).replace(/\/+$/u, "");
56
+ const encodedEntryPath = options.entryPath
57
+ .split("/")
58
+ .filter((segment) => segment.length > 0)
59
+ .map((segment) => encodeURIComponent(segment))
60
+ .join("/");
61
+ const path = encodedEntryPath.length === 0 ? basePath : `${basePath}/${encodedEntryPath}`;
62
+ return `${path}?g=${encodeURIComponent(options.generation)}`;
74
63
  }
75
- function projectionWithServerState(options) {
64
+ function projectionWithState(options) {
76
65
  const artifact = options.declaration.manifest.artifact;
77
66
  const baseProjection = {
78
67
  status: "declared",
79
- manifest_path: "tutti.artifact.json",
80
- manifest_blob_oid: options.declaration.manifest_blob_oid,
68
+ preview_generation: options.state.generation,
81
69
  artifact: {
82
70
  title: artifact.title,
83
- kind: "server",
71
+ kind: artifact.kind,
84
72
  network: artifact.network,
85
73
  },
86
74
  preview: {
87
- status: "stopped",
75
+ status: "idle",
88
76
  },
89
77
  };
90
78
  const state = options.state;
91
- if (state === undefined || state.key !== serverDeclarationKey(options.declaration)) {
92
- return baseProjection;
93
- }
94
79
  if (state.status === "ready") {
80
+ const url = artifact.kind === "static" && options.declaration.runtime.kind === "static"
81
+ ? buildStaticPreviewUrl({
82
+ basePath: options.previewBasePath,
83
+ entryPath: options.declaration.runtime.entry_path,
84
+ generation: state.generation,
85
+ })
86
+ : buildServerPreviewUrl({
87
+ basePath: options.previewBasePath,
88
+ entryPath: state.entryPath,
89
+ generation: state.generation,
90
+ });
95
91
  return {
96
92
  ...baseProjection,
97
93
  preview: {
98
94
  status: "ready",
99
- url: buildServerPreviewUrl({
100
- basePath: options.previewBasePath,
101
- entryPath: state.entryPath,
102
- manifestBlobOid: state.manifestBlobOid,
103
- instanceId: state.instanceId,
104
- }),
95
+ url,
105
96
  },
106
97
  };
107
98
  }
@@ -215,8 +206,7 @@ function safeRedirectLocation(options) {
215
206
  if (redirected.origin !== options.localUrl.origin) {
216
207
  throw new WorkspaceOpsError("validation_failed", "Artifact preview redirect left the managed preview instance");
217
208
  }
218
- redirected.searchParams.set("v", options.state.manifestBlobOid);
219
- redirected.searchParams.set("preview", options.state.instanceId);
209
+ redirected.searchParams.set("g", options.state.generation);
220
210
  return `${ARTIFACT_PREVIEW_BASE_PATH}${redirected.pathname}${redirected.search}${redirected.hash}`;
221
211
  }
222
212
  function unrefTimer(timer) {
@@ -237,7 +227,6 @@ export function isArtifactPreviewHttpMethod(method) {
237
227
  method === "OPTIONS");
238
228
  }
239
229
  export class ArtifactPreviewManager {
240
- now;
241
230
  baseEnv;
242
231
  spawnProcess;
243
232
  terminateProcess;
@@ -257,10 +246,14 @@ export class ArtifactPreviewManager {
257
246
  responseBodyLimitBytes;
258
247
  idleStopTtlMs;
259
248
  pendingTasks = new Set();
249
+ leases = new Map();
250
+ leaseSweepTimer;
260
251
  state;
261
- nextInstanceSequence = 0;
252
+ declaration;
253
+ lastProjection = { status: "not_declared" };
254
+ workspaceUpdating = false;
255
+ nextGenerationSequence = 0;
262
256
  constructor(options = {}) {
263
- this.now = options.now ?? (() => new Date());
264
257
  this.baseEnv = options.baseEnv ?? process.env;
265
258
  this.spawnProcess = options.spawnProcess ?? spawnArtifactPreviewProcess;
266
259
  this.terminateProcess = options.terminateProcess ?? terminateArtifactProcessTree;
@@ -283,187 +276,40 @@ export class ArtifactPreviewManager {
283
276
  options.responseBodyLimitBytes ?? ARTIFACT_PREVIEW_RESPONSE_BODY_LIMIT_BYTES;
284
277
  this.idleStopTtlMs = options.idleStopTtlMs ?? DEFAULT_IDLE_STOP_TTL_MS;
285
278
  }
286
- readProjection(options) {
287
- const declaration = readArtifactDeclaration({
288
- workspaceRoot: options.workspaceRoot,
289
- previewBasePath: options.previewBasePath,
290
- now: this.now,
291
- });
292
- this.stopStaleRuntime(declaration);
293
- return {
294
- artifact: isServerDeclaration(declaration)
295
- ? projectionWithServerState({
296
- declaration,
297
- state: this.state,
298
- previewBasePath: options.previewBasePath,
299
- })
300
- : declaration.projection,
301
- };
302
- }
303
- start(options) {
304
- const declaration = readArtifactDeclaration({
305
- workspaceRoot: options.workspaceRoot,
306
- previewBasePath: options.previewBasePath,
307
- now: this.now,
308
- });
309
- this.stopStaleRuntime(declaration);
310
- if (declaration.status === "not_declared") {
311
- return {
312
- disposition: { kind: "not_declared" },
313
- result: { artifact: declaration.projection },
314
- };
315
- }
316
- if (declaration.status === "invalid") {
317
- return {
318
- disposition: declarationInvalidDisposition(declaration).start,
319
- result: { artifact: declaration.projection },
320
- };
321
- }
322
- if (!isServerDeclaration(declaration)) {
323
- return {
324
- disposition: { kind: "unsupported" },
325
- result: { artifact: declaration.projection },
326
- };
327
- }
328
- const key = serverDeclarationKey(declaration);
329
- if (this.state?.key === key) {
330
- if (this.state.status === "ready") {
331
- this.recordHeartbeat(this.state);
332
- return {
333
- disposition: { kind: "already_running" },
334
- result: {
335
- artifact: projectionWithServerState({
336
- declaration,
337
- state: this.state,
338
- previewBasePath: options.previewBasePath,
339
- }),
340
- },
341
- };
342
- }
343
- if (this.state.status === "starting") {
344
- this.recordHeartbeat(this.state);
345
- return {
346
- disposition: { kind: "starting" },
347
- result: {
348
- artifact: projectionWithServerState({
349
- declaration,
350
- state: this.state,
351
- previewBasePath: options.previewBasePath,
352
- }),
353
- },
354
- };
355
- }
356
- this.stopCurrentProcess();
279
+ readProjection(options = {}) {
280
+ if (this.declaration === undefined && options.workspaceRoot !== undefined) {
281
+ this.initializeWorkspace({ workspaceRoot: options.workspaceRoot });
357
282
  }
358
- this.startProcess(options.workspaceRoot, declaration);
359
283
  return {
360
- disposition: { kind: "started" },
361
- result: {
362
- artifact: projectionWithServerState({
363
- declaration,
364
- state: this.state,
365
- previewBasePath: options.previewBasePath,
366
- }),
367
- },
284
+ artifact: this.currentProjection(options.previewBasePath),
368
285
  };
369
286
  }
370
- heartbeat(options) {
371
- const declaration = readArtifactDeclaration({
372
- workspaceRoot: options.workspaceRoot,
373
- previewBasePath: options.previewBasePath,
374
- now: this.now,
375
- });
376
- this.stopStaleRuntime(declaration);
377
- if (declaration.status === "not_declared") {
378
- return {
379
- disposition: { kind: "not_declared" },
380
- result: { artifact: declaration.projection },
381
- };
382
- }
383
- if (declaration.status === "invalid") {
384
- return {
385
- disposition: declarationInvalidDisposition(declaration).heartbeat,
386
- result: { artifact: declaration.projection },
387
- };
388
- }
389
- if (!isServerDeclaration(declaration) ||
390
- this.state?.key !== serverDeclarationKey(declaration)) {
391
- return {
392
- disposition: { kind: "not_running" },
393
- result: {
394
- artifact: isServerDeclaration(declaration)
395
- ? projectionWithServerState({
396
- declaration,
397
- state: this.state,
398
- previewBasePath: options.previewBasePath,
399
- })
400
- : declaration.projection,
401
- },
402
- };
403
- }
404
- if (this.state.status !== "starting" && this.state.status !== "ready") {
405
- return {
406
- disposition: { kind: "not_running" },
407
- result: {
408
- artifact: projectionWithServerState({
409
- declaration,
410
- state: this.state,
411
- previewBasePath: options.previewBasePath,
412
- }),
413
- },
414
- };
287
+ initializeWorkspace(options) {
288
+ if (this.declaration !== undefined || this.state !== undefined) {
289
+ return;
415
290
  }
416
- this.recordHeartbeat(this.state);
417
- return {
418
- disposition: { kind: "recorded" },
419
- result: {
420
- artifact: projectionWithServerState({
421
- declaration,
422
- state: this.state,
423
- previewBasePath: options.previewBasePath,
424
- }),
425
- },
426
- };
291
+ this.refreshDeclaration(options.workspaceRoot, false, false);
427
292
  }
428
- stop(options) {
429
- const declaration = readArtifactDeclaration({
430
- workspaceRoot: options.workspaceRoot,
431
- previewBasePath: options.previewBasePath,
432
- now: this.now,
433
- });
434
- if (declaration.status === "not_declared") {
435
- this.stopCurrentProcess();
436
- return {
437
- disposition: { kind: "not_declared" },
438
- result: { artifact: declaration.projection },
439
- };
440
- }
441
- if (declaration.status === "invalid") {
442
- this.stopCurrentProcess();
443
- return {
444
- disposition: declarationInvalidDisposition(declaration).stop,
445
- result: { artifact: declaration.projection },
446
- };
293
+ renewLease(options) {
294
+ const nowMs = this.clockMs();
295
+ this.pruneExpiredLeases(nowMs);
296
+ const hadActiveLease = this.leases.size > 0;
297
+ this.leases.set(options.viewerId, nowMs);
298
+ this.scheduleLeaseSweep(nowMs);
299
+ if (!hadActiveLease) {
300
+ this.ensurePreview(options.workspaceRoot);
447
301
  }
448
- if (!isServerDeclaration(declaration) ||
449
- this.state?.key !== serverDeclarationKey(declaration)) {
450
- return {
451
- disposition: { kind: "not_running" },
452
- result: { artifact: declaration.projection },
453
- };
302
+ }
303
+ beginWorkspaceUpdate() {
304
+ if (this.workspaceUpdating) {
305
+ return;
454
306
  }
455
- const wasRunning = this.state.status === "starting" || this.state.status === "ready";
307
+ this.workspaceUpdating = true;
456
308
  this.stopCurrentProcess();
457
- return {
458
- disposition: wasRunning ? { kind: "stopped" } : { kind: "not_running" },
459
- result: {
460
- artifact: projectionWithServerState({
461
- declaration,
462
- state: this.state,
463
- previewBasePath: options.previewBasePath,
464
- }),
465
- },
466
- };
309
+ }
310
+ settleWorkspaceUpdate(options) {
311
+ this.workspaceUpdating = false;
312
+ this.refreshDeclaration(options.workspaceRoot, this.leases.size > 0);
467
313
  }
468
314
  async fetchServerPreview(options) {
469
315
  if (options.body !== undefined && options.body.byteLength > this.requestBodyLimitBytes) {
@@ -472,13 +318,8 @@ export class ArtifactPreviewManager {
472
318
  if ((options.method === "GET" || options.method === "HEAD") && options.body !== undefined) {
473
319
  throw new WorkspaceOpsError("bad_request", "Artifact preview request method cannot have a body");
474
320
  }
475
- const declaration = readArtifactDeclaration({
476
- workspaceRoot: options.workspaceRoot,
477
- now: this.now,
478
- });
479
- this.stopStaleRuntime(declaration);
480
321
  const state = this.state;
481
- if (!isServerDeclaration(declaration) || state?.key !== serverDeclarationKey(declaration)) {
322
+ if (state?.declaration.runtime.kind !== "server") {
482
323
  throw new WorkspaceOpsError("not_found", "Artifact preview is not running");
483
324
  }
484
325
  if (state.status !== "ready" || state.port === undefined || !processIsAlive(state.child)) {
@@ -490,16 +331,11 @@ export class ArtifactPreviewManager {
490
331
  const requestPath = normalizePreviewRequestPath(options.path);
491
332
  const localUrl = new URL(`http://127.0.0.1:${state.port}${requestPath}`);
492
333
  const query = new URLSearchParams(options.query?.replace(/^\?/u, "") ?? "");
493
- const requestedManifestBlobOid = query.get("v");
494
- if (requestedManifestBlobOid !== null && requestedManifestBlobOid !== state.manifestBlobOid) {
495
- throw new WorkspaceOpsError("not_found", "Artifact preview manifest is stale");
496
- }
497
- const requestedInstanceId = query.get("preview");
498
- if (requestedInstanceId !== null && requestedInstanceId !== state.instanceId) {
499
- throw new WorkspaceOpsError("not_found", "Artifact preview instance is stale");
334
+ const requestedGeneration = query.get("g");
335
+ if (requestedGeneration !== state.generation) {
336
+ throw new WorkspaceOpsError("not_found", "Artifact preview generation is stale");
500
337
  }
501
- query.delete("v");
502
- query.delete("preview");
338
+ query.delete("g");
503
339
  localUrl.search = query.toString();
504
340
  const requestHeaders = sanitizeRequestHeaders(options.headers);
505
341
  if (options.headers?.origin !== undefined) {
@@ -551,41 +387,142 @@ export class ArtifactPreviewManager {
551
387
  this.clearTimeoutFn(timeout);
552
388
  }
553
389
  }
554
- sweepIdle() {
555
- if (this.state === undefined || this.state.status === "stopped") {
556
- return;
390
+ readStaticPreview(options) {
391
+ const state = this.state;
392
+ if (state?.declaration.runtime.kind !== "static" ||
393
+ state.status !== "ready" ||
394
+ this.workspaceUpdating) {
395
+ throw new WorkspaceOpsError("not_found", "Artifact preview is not ready");
557
396
  }
558
- if (this.clockMs() - this.state.lastHeartbeatAtMs < this.idleStopTtlMs) {
559
- this.scheduleIdleSweep(this.state);
560
- return;
397
+ const query = new URLSearchParams(options.query?.replace(/^\?/u, "") ?? "");
398
+ const requestedGeneration = query.get("g");
399
+ if (requestedGeneration !== state.generation) {
400
+ throw new WorkspaceOpsError("not_found", "Artifact preview generation is stale");
561
401
  }
562
- this.stopCurrentProcess();
402
+ return readArtifactStaticPreview({
403
+ workspaceRoot: options.workspaceRoot,
404
+ path: options.path,
405
+ declaration: state.declaration,
406
+ });
563
407
  }
564
408
  async stopAll() {
409
+ this.leases.clear();
410
+ if (this.leaseSweepTimer !== undefined) {
411
+ this.clearTimeoutFn(this.leaseSweepTimer);
412
+ this.leaseSweepTimer = undefined;
413
+ }
565
414
  this.stopCurrentProcess();
566
415
  while (this.pendingTasks.size > 0) {
567
416
  await Promise.all([...this.pendingTasks]);
568
417
  }
569
418
  }
419
+ currentProjection(previewBasePath) {
420
+ const state = this.state;
421
+ return state === undefined
422
+ ? this.lastProjection
423
+ : projectionWithState({
424
+ declaration: state.declaration,
425
+ state,
426
+ ...(previewBasePath === undefined ? {} : { previewBasePath }),
427
+ });
428
+ }
429
+ ensurePreview(workspaceRoot) {
430
+ const current = this.state;
431
+ if (this.workspaceUpdating && current !== undefined) {
432
+ return current.declaration;
433
+ }
434
+ if (current !== undefined &&
435
+ (current.status === "starting" ||
436
+ current.status === "ready" ||
437
+ current.status === "failed")) {
438
+ return current.declaration;
439
+ }
440
+ const declaration = current?.declaration ?? this.declaration;
441
+ if (declaration !== undefined) {
442
+ return this.applyDeclaration(workspaceRoot, declaration, true);
443
+ }
444
+ return this.refreshDeclaration(workspaceRoot, true);
445
+ }
446
+ refreshDeclaration(workspaceRoot, activate, notify = true) {
447
+ const declaration = readLiveArtifactDeclaration({ workspaceRoot });
448
+ this.declaration = declaration;
449
+ return this.applyDeclaration(workspaceRoot, declaration, activate, notify);
450
+ }
451
+ applyDeclaration(workspaceRoot, declaration, activate, notify = true) {
452
+ if (this.state !== undefined) {
453
+ this.stopCurrentProcess();
454
+ this.state = undefined;
455
+ }
456
+ if (declaration.status !== "declared") {
457
+ this.lastProjection = declaration.projection;
458
+ if (notify) {
459
+ this.onStatusChange();
460
+ }
461
+ return declaration;
462
+ }
463
+ if (!activate) {
464
+ const state = this.createIdleState(declaration);
465
+ this.state = state;
466
+ this.lastProjection = projectionWithState({ declaration, state });
467
+ if (notify) {
468
+ this.onStatusChange();
469
+ }
470
+ return declaration;
471
+ }
472
+ if (declaration.runtime.kind === "server") {
473
+ this.startProcess(workspaceRoot, declaration);
474
+ return declaration;
475
+ }
476
+ const generation = this.nextGeneration();
477
+ const state = {
478
+ generation,
479
+ declaration,
480
+ entryPath: declaration.runtime.entry_path,
481
+ cwdPath: "",
482
+ status: "ready",
483
+ child: undefined,
484
+ processRuntime: undefined,
485
+ port: undefined,
486
+ launchAttempt: 0,
487
+ errorMessage: undefined,
488
+ diagnostics: new ArtifactDiagnosticBuffer(),
489
+ };
490
+ this.state = state;
491
+ this.lastProjection = projectionWithState({ declaration, state });
492
+ this.onStatusChange();
493
+ return declaration;
494
+ }
495
+ createIdleState(declaration) {
496
+ return {
497
+ generation: this.nextGeneration(),
498
+ declaration,
499
+ entryPath: declaration.runtime.entry_path,
500
+ cwdPath: "",
501
+ status: "idle",
502
+ child: undefined,
503
+ processRuntime: undefined,
504
+ port: undefined,
505
+ launchAttempt: 0,
506
+ errorMessage: undefined,
507
+ diagnostics: new ArtifactDiagnosticBuffer(),
508
+ };
509
+ }
570
510
  startProcess(workspaceRoot, declaration) {
511
+ const generation = this.nextGeneration();
571
512
  const state = {
572
- key: serverDeclarationKey(declaration),
573
- manifestBlobOid: declaration.manifest_blob_oid,
513
+ generation,
514
+ declaration,
574
515
  entryPath: declaration.runtime.entry_path,
575
516
  cwdPath: resolveServerCwd(workspaceRoot, declaration.runtime.cwd_path),
576
517
  status: "starting",
577
518
  child: undefined,
578
519
  processRuntime: undefined,
579
520
  port: undefined,
580
- instanceId: this.nextInstanceId(),
581
521
  launchAttempt: 0,
582
- lastHeartbeatAtMs: this.clockMs(),
583
522
  errorMessage: undefined,
584
523
  diagnostics: new ArtifactDiagnosticBuffer(),
585
- idleTimer: undefined,
586
524
  };
587
525
  this.state = state;
588
- this.scheduleIdleSweep(state);
589
526
  this.onStatusChange();
590
527
  this.trackTask(this.launchProcess(state));
591
528
  }
@@ -633,7 +570,7 @@ export class ArtifactPreviewManager {
633
570
  }
634
571
  });
635
572
  child.on("close", (code, signal) => {
636
- if (this.state !== state || state.child !== child || state.status === "stopped") {
573
+ if (this.state !== state || state.child !== child || state.status === "idle") {
637
574
  this.cleanupStateRuntime(state, runtime);
638
575
  return;
639
576
  }
@@ -746,50 +683,55 @@ export class ArtifactPreviewManager {
746
683
  }
747
684
  return { kind: "invalid", message: "Preview entry redirected too many times" };
748
685
  }
749
- recordHeartbeat(state) {
750
- state.lastHeartbeatAtMs = this.clockMs();
751
- this.scheduleIdleSweep(state);
752
- }
753
- scheduleIdleSweep(state) {
754
- if (state.idleTimer !== undefined) {
755
- this.clearTimeoutFn(state.idleTimer);
686
+ pruneExpiredLeases(nowMs) {
687
+ for (const [viewerId, renewedAtMs] of this.leases) {
688
+ if (nowMs - renewedAtMs >= this.idleStopTtlMs) {
689
+ this.leases.delete(viewerId);
690
+ }
756
691
  }
757
- state.idleTimer = this.setTimeoutFn(() => this.sweepIdle(), this.idleStopTtlMs);
758
- unrefTimer(state.idleTimer);
759
692
  }
760
- stopStaleRuntime(declaration) {
761
- if (this.state === undefined) {
762
- return;
693
+ scheduleLeaseSweep(nowMs = this.clockMs()) {
694
+ if (this.leaseSweepTimer !== undefined) {
695
+ this.clearTimeoutFn(this.leaseSweepTimer);
696
+ this.leaseSweepTimer = undefined;
763
697
  }
764
- if (!isServerDeclaration(declaration) || this.state.key !== serverDeclarationKey(declaration)) {
765
- this.stopCurrentProcess();
766
- this.state = undefined;
698
+ if (this.leases.size === 0) {
699
+ return;
767
700
  }
701
+ let earliestExpiryMs = Number.POSITIVE_INFINITY;
702
+ for (const renewedAtMs of this.leases.values()) {
703
+ earliestExpiryMs = Math.min(earliestExpiryMs, renewedAtMs + this.idleStopTtlMs);
704
+ }
705
+ this.leaseSweepTimer = this.setTimeoutFn(() => {
706
+ this.leaseSweepTimer = undefined;
707
+ const sweepAtMs = this.clockMs();
708
+ this.pruneExpiredLeases(sweepAtMs);
709
+ if (this.leases.size === 0) {
710
+ if (this.state?.status !== "failed") {
711
+ this.stopCurrentProcess();
712
+ }
713
+ return;
714
+ }
715
+ this.scheduleLeaseSweep(sweepAtMs);
716
+ }, Math.max(0, earliestExpiryMs - nowMs));
717
+ unrefTimer(this.leaseSweepTimer);
768
718
  }
769
719
  stopCurrentProcess() {
770
720
  const state = this.state;
771
- if (state === undefined) {
721
+ if (state === undefined || state.status === "idle") {
772
722
  return;
773
723
  }
774
- if (state.idleTimer !== undefined) {
775
- this.clearTimeoutFn(state.idleTimer);
776
- state.idleTimer = undefined;
777
- }
778
724
  this.disposeStateProcess(state);
779
- state.status = "stopped";
725
+ state.status = "idle";
780
726
  state.errorMessage = undefined;
781
727
  this.onStatusChange();
782
728
  }
783
729
  failState(state, message) {
784
- if (this.state !== state || state.status === "stopped") {
730
+ if (this.state !== state || state.status === "idle") {
785
731
  return;
786
732
  }
787
733
  state.status = "failed";
788
734
  state.errorMessage = redactAndTruncateText(message, 220);
789
- if (state.idleTimer !== undefined) {
790
- this.clearTimeoutFn(state.idleTimer);
791
- state.idleTimer = undefined;
792
- }
793
735
  this.disposeStateProcess(state);
794
736
  this.onStatusChange();
795
737
  }
@@ -822,9 +764,9 @@ export class ArtifactPreviewManager {
822
764
  this.pendingTasks.add(task);
823
765
  void task.finally(() => this.pendingTasks.delete(task));
824
766
  }
825
- nextInstanceId() {
826
- this.nextInstanceSequence += 1;
827
- return `artifact_preview_${this.nextInstanceSequence}`;
767
+ nextGeneration() {
768
+ this.nextGenerationSequence += 1;
769
+ return `artifact_preview_${this.nextGenerationSequence}`;
828
770
  }
829
771
  }
830
772
  export const ARTIFACT_PREVIEW_DEFAULT_BASE_PATH = ARTIFACT_PREVIEW_BASE_PATH;
@@ -1,3 +1,4 @@
1
+ import { type LiveArtifactDeclaration } from "./live-manifest.js";
1
2
  export declare const ARTIFACT_PREVIEW_CSP: string;
2
3
  export type ArtifactStaticPreviewFile = {
3
4
  media_type: string;
@@ -6,7 +7,7 @@ export type ArtifactStaticPreviewFile = {
6
7
  export type ReadArtifactStaticPreviewOptions = {
7
8
  workspaceRoot: string;
8
9
  path: string | undefined;
9
- now?: () => Date;
10
+ declaration?: LiveArtifactDeclaration;
10
11
  };
11
12
  export declare function rewriteArtifactPreviewResourceUrls(options: {
12
13
  mediaType: string | undefined;