@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-1

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 (46) hide show
  1. package/README.md +52 -8
  2. package/cli.mjs +538 -224
  3. package/index.ts +76 -27
  4. package/openclaw.plugin.json +53 -28
  5. package/package.json +5 -2
  6. package/skills/teamclaw/SKILL.md +213 -0
  7. package/skills/teamclaw/references/api-quick-ref.md +117 -0
  8. package/skills/teamclaw-setup/SKILL.md +81 -0
  9. package/skills/teamclaw-setup/references/install-modes.md +136 -0
  10. package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
  11. package/src/config.ts +44 -16
  12. package/src/controller/controller-capacity.ts +2 -2
  13. package/src/controller/controller-service.ts +193 -47
  14. package/src/controller/controller-tools.ts +102 -2
  15. package/src/controller/delivery-report.ts +563 -0
  16. package/src/controller/http-server.ts +1907 -172
  17. package/src/controller/kickoff-orchestrator.ts +292 -0
  18. package/src/controller/managed-gateway-process.ts +330 -0
  19. package/src/controller/orchestration-manifest.ts +69 -1
  20. package/src/controller/preview-manager.ts +676 -0
  21. package/src/controller/prompt-injector.ts +116 -67
  22. package/src/controller/role-inference.ts +41 -0
  23. package/src/controller/websocket.ts +3 -1
  24. package/src/controller/worker-provisioning.ts +429 -74
  25. package/src/discovery.ts +1 -1
  26. package/src/git-collaboration.ts +198 -47
  27. package/src/identity.ts +12 -2
  28. package/src/interaction-contracts.ts +179 -3
  29. package/src/networking.ts +99 -0
  30. package/src/openclaw-workspace.ts +478 -11
  31. package/src/prompt-policy.ts +381 -0
  32. package/src/roles.ts +37 -36
  33. package/src/state.ts +40 -1
  34. package/src/task-executor.ts +282 -78
  35. package/src/types.ts +150 -7
  36. package/src/ui/app.js +1403 -175
  37. package/src/ui/assets/teamclaw-app-icon.png +0 -0
  38. package/src/ui/index.html +122 -40
  39. package/src/ui/style.css +829 -143
  40. package/src/worker/http-handler.ts +40 -4
  41. package/src/worker/prompt-injector.ts +9 -38
  42. package/src/worker/skill-installer.ts +2 -2
  43. package/src/worker/tools.ts +31 -5
  44. package/src/worker/worker-service.ts +49 -8
  45. package/src/workspace-browser.ts +20 -7
  46. package/src/controller/local-worker-manager.ts +0 -533
@@ -0,0 +1,676 @@
1
+ import net from "node:net";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import type { PluginLogger } from "../../api.js";
6
+ import { resolveTeamClawAgentWorkspaceRootDir } from "../openclaw-workspace.js";
7
+ import type { DynamicPreviewRecord, TeamState, WorkerTaskResultContract, WorkerTaskResultDeliverable } from "../types.js";
8
+ import { spawnManagedCommandProcess, stopManagedProcess } from "./managed-gateway-process.js";
9
+
10
+ const PREVIEW_LAUNCH_TIMEOUT_MS = 120_000;
11
+ const PREVIEW_HEALTH_INTERVAL_MS = 1_500;
12
+ const PREVIEW_STOP_TIMEOUT_MS = 5_000;
13
+
14
+ const GENERIC_SERVE_COMMAND = "npx -y serve -l {PORT}";
15
+
16
+ /**
17
+ * Detect the tech stack from files in `cwd` and return a suitable preview command.
18
+ * Only overrides `existingCommand` when it is the generic static file server
19
+ * and the deliverable explicitly specifies a runnable framework.
20
+ *
21
+ * IMPORTANT: The worker (LLM) is responsible for providing the real preview
22
+ * command in its result contract. This function only provides a lightweight
23
+ * fallback for static file directories.
24
+ */
25
+ async function resolveSmartPreviewCommand(cwd: string, existingCommand: string): Promise<string> {
26
+ if (existingCommand !== GENERIC_SERVE_COMMAND) {
27
+ return existingCommand;
28
+ }
29
+ try {
30
+ const entries = await fs.readdir(cwd, { withFileTypes: true });
31
+ const filenames = entries.filter((e) => e.isFile()).map((e) => e.name);
32
+
33
+ // Node.js — detect package.json with dev/start script
34
+ if (filenames.includes("package.json")) {
35
+ const pkgRaw = await fs.readFile(path.join(cwd, "package.json"), "utf-8").catch(() => "");
36
+ if (pkgRaw) {
37
+ try {
38
+ const pkg = JSON.parse(pkgRaw) as { scripts?: Record<string, string> };
39
+ if (pkg.scripts?.dev) {
40
+ return "npm run dev -- --port {PORT}";
41
+ }
42
+ if (pkg.scripts?.start) {
43
+ return "npm start -- --port {PORT}";
44
+ }
45
+ } catch {
46
+ // ignore parse errors
47
+ }
48
+ }
49
+ }
50
+
51
+ // Python — detect requirements.txt or pyproject.toml and pick the right runner
52
+ if (filenames.includes("requirements.txt") || filenames.includes("pyproject.toml")) {
53
+ // Prefer venv python if available (workers typically create a venv)
54
+ const dirnames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
55
+ const venvDir = dirnames.find((d) => d === "venv" || d === ".venv" || d === "env");
56
+ const pythonBin = venvDir ? `${venvDir}/bin/python` : "python";
57
+
58
+ // Check for a main.py that imports FastAPI/Flask
59
+ const mainPy = filenames.includes("main.py")
60
+ ? await fs.readFile(path.join(cwd, "main.py"), "utf-8").catch(() => "")
61
+ : filenames.includes("app.py")
62
+ ? await fs.readFile(path.join(cwd, "app.py"), "utf-8").catch(() => "")
63
+ : "";
64
+ if (mainPy) {
65
+ const entryFile = filenames.includes("main.py") ? "main" : "app";
66
+ if (/from\s+fastapi\b|import\s+fastapi/i.test(mainPy)) {
67
+ const appMatch = mainPy.match(/(\w+)\s*=\s*FastAPI\s*\(/);
68
+ const appVar = appMatch?.[1] ?? "app";
69
+ return `${pythonBin} -m uvicorn ${entryFile}:${appVar} --host 0.0.0.0 --port {PORT}`;
70
+ }
71
+ if (/from\s+flask\b|import\s+flask/i.test(mainPy)) {
72
+ return `${pythonBin} -m flask --app ${entryFile} run --host 0.0.0.0 --port {PORT}`;
73
+ }
74
+ }
75
+ // Generic Python with manage.py (Django)
76
+ if (filenames.includes("manage.py")) {
77
+ return `${pythonBin} manage.py runserver 0.0.0.0:{PORT}`;
78
+ }
79
+ }
80
+
81
+ // Java — detect pom.xml (Maven / Spring Boot)
82
+ if (filenames.includes("pom.xml")) {
83
+ const pomRaw = await fs.readFile(path.join(cwd, "pom.xml"), "utf-8").catch(() => "");
84
+ if (pomRaw.includes("spring-boot")) {
85
+ return "mvn spring-boot:run -Dspring-boot.run.arguments=--server.port={PORT}";
86
+ }
87
+ // Gradle wrapper
88
+ }
89
+ if (filenames.includes("build.gradle") || filenames.includes("build.gradle.kts")) {
90
+ return "./gradlew bootRun --args='--server.port={PORT}'";
91
+ }
92
+
93
+ // Go — detect go.mod
94
+ if (filenames.includes("go.mod")) {
95
+ if (filenames.includes("main.go")) {
96
+ return "go run . --port {PORT}";
97
+ }
98
+ }
99
+
100
+ // Rust — detect Cargo.toml
101
+ if (filenames.includes("Cargo.toml")) {
102
+ return "cargo run -- --port {PORT}";
103
+ }
104
+ } catch {
105
+ // cwd may not exist yet or be unreadable
106
+ }
107
+ return existingCommand;
108
+ }
109
+
110
+ type PreviewManagerDeps = {
111
+ logger: PluginLogger;
112
+ getTeamState: () => TeamState | null;
113
+ updateTeamState: (updater: (state: TeamState) => void) => TeamState;
114
+ };
115
+
116
+ type DynamicPreviewSpec = {
117
+ previewId: string;
118
+ taskId: string;
119
+ deliverableIndex: number;
120
+ deliverableValue: string;
121
+ previewCommand: string;
122
+ previewCwd: string;
123
+ previewReadyPath: string;
124
+ liveUrl: string;
125
+ };
126
+
127
+ function normalizeOptionalText(value: unknown): string | undefined {
128
+ if (typeof value !== "string") {
129
+ return undefined;
130
+ }
131
+ const normalized = value.trim();
132
+ return normalized || undefined;
133
+ }
134
+
135
+ function normalizePreviewReadyPath(value: unknown): string {
136
+ const normalized = normalizeOptionalText(value);
137
+ if (!normalized) {
138
+ return "/";
139
+ }
140
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
141
+ }
142
+
143
+ function normalizePreviewCwd(deliverable: WorkerTaskResultDeliverable): string | undefined {
144
+ const explicit = normalizeOptionalText(deliverable.previewCwd);
145
+ if (explicit) {
146
+ return explicit;
147
+ }
148
+ const value = normalizeOptionalText(deliverable.value);
149
+ if (!value) {
150
+ return undefined;
151
+ }
152
+ if (deliverable.kind === "directory") {
153
+ return value;
154
+ }
155
+ const directory = path.posix.dirname(value);
156
+ return directory === "." ? "." : directory;
157
+ }
158
+
159
+ function buildPreviewId(taskId: string, deliverableIndex: number): string {
160
+ return `preview-${taskId}-${deliverableIndex}`;
161
+ }
162
+
163
+ function buildPreviewLiveUrl(previewId: string): string {
164
+ return `/api/v1/previews/${encodeURIComponent(previewId)}/`;
165
+ }
166
+
167
+ function isWorkspacePathInsideRoot(rootDir: string, targetPath: string): boolean {
168
+ const relative = path.relative(rootDir, targetPath);
169
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
170
+ }
171
+
172
+ function resolveWorkspaceDirectory(rootDir: string, relativePath: string): string | null {
173
+ const resolved = path.resolve(rootDir, relativePath);
174
+ return isWorkspacePathInsideRoot(rootDir, resolved) ? resolved : null;
175
+ }
176
+
177
+ export class PreviewManager {
178
+ private readonly processes = new Map<string, ChildProcess>();
179
+ private readonly stoppingPreviewIds = new Set<string>();
180
+ private healthMonitorTimer: ReturnType<typeof setInterval> | null = null;
181
+
182
+ private static readonly HEALTH_MONITOR_INTERVAL_MS = 30_000; // check every 30s
183
+
184
+ constructor(private readonly deps: PreviewManagerDeps) {}
185
+
186
+ /** Start periodic health monitoring for all healthy previews. */
187
+ startHealthMonitor(): void {
188
+ if (this.healthMonitorTimer) return;
189
+ this.healthMonitorTimer = setInterval(() => {
190
+ void this.runHealthChecks();
191
+ }, PreviewManager.HEALTH_MONITOR_INTERVAL_MS);
192
+ this.healthMonitorTimer.unref?.();
193
+ }
194
+
195
+ stopHealthMonitor(): void {
196
+ if (this.healthMonitorTimer) {
197
+ clearInterval(this.healthMonitorTimer);
198
+ this.healthMonitorTimer = null;
199
+ }
200
+ }
201
+
202
+ /** Re-check all "healthy" previews; mark failed if process gone or not responding. */
203
+ private async runHealthChecks(): Promise<void> {
204
+ const state = this.deps.getTeamState();
205
+ if (!state) return;
206
+ const healthyPreviews = Object.values(state.previews ?? {}).filter(
207
+ (p) => p.status === "healthy",
208
+ );
209
+ for (const preview of healthyPreviews) {
210
+ // Check if process is still alive
211
+ const proc = this.processes.get(preview.id);
212
+ if (!proc) {
213
+ this.markPreviewFailed(preview.id, "Preview process is no longer running.");
214
+ continue;
215
+ }
216
+ // Check if still responding to HTTP
217
+ const healthy = await this.checkPreviewHealth(preview);
218
+ if (!healthy) {
219
+ this.deps.logger.warn(`Controller: preview ${preview.id} failed periodic health check`);
220
+ this.markPreviewFailed(preview.id, "Preview stopped responding to health checks.");
221
+ } else {
222
+ this.deps.updateTeamState((s) => {
223
+ const p = s.previews?.[preview.id];
224
+ if (p) p.lastHealthCheckAt = Date.now();
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ async syncTaskPreviews(taskId: string): Promise<void> {
231
+ const state = this.deps.getTeamState();
232
+ const task = state?.tasks[taskId];
233
+ if (!task || task.status !== "completed" || !task.resultContract) {
234
+ await this.stopTaskPreviews(taskId, !task ? "task removed" : `task moved to ${task.status}`);
235
+ return;
236
+ }
237
+
238
+ const specs = await this.collectPreviewSpecs(taskId, task.resultContract);
239
+ const desiredIds = new Set(specs.map((spec) => spec.previewId));
240
+ const existingIds = Object.values(state?.previews ?? {})
241
+ .filter((preview) => preview.taskId === taskId)
242
+ .map((preview) => preview.id);
243
+
244
+ for (const previewId of existingIds) {
245
+ if (!desiredIds.has(previewId)) {
246
+ await this.removePreview(previewId, "preview no longer declared on the task result");
247
+ }
248
+ }
249
+
250
+ for (const spec of specs) {
251
+ await this.ensurePreview(spec);
252
+ }
253
+ if (specs.length > 0) this.startHealthMonitor();
254
+ }
255
+
256
+ async stopTaskPreviews(taskId: string, reason: string): Promise<void> {
257
+ const previewIds = Object.values(this.deps.getTeamState()?.previews ?? {})
258
+ .filter((preview) => preview.taskId === taskId)
259
+ .map((preview) => preview.id);
260
+ for (const previewId of previewIds) {
261
+ await this.removePreview(previewId, reason);
262
+ }
263
+ }
264
+
265
+ private static isPreviewableDeliverable(d: WorkerTaskResultDeliverable): boolean {
266
+ return d.artifactType === "web-app" || d.artifactType === "static-site" || d.artifactType === "rest-api";
267
+ }
268
+
269
+ async restorePreviewsOnStartup(): Promise<void> {
270
+ const state = this.deps.getTeamState();
271
+ if (!state) {
272
+ this.deps.logger.warn("PreviewManager: restorePreviewsOnStartup called but no team state");
273
+ return;
274
+ }
275
+ const completedTasks = Object.values(state.tasks).filter((t) => t.status === "completed" && t.resultContract);
276
+ const tasksWithPreviews = completedTasks.filter((t) =>
277
+ t.resultContract!.deliverables.some((d) => PreviewManager.isPreviewableDeliverable(d)),
278
+ );
279
+ this.deps.logger.info(`PreviewManager: restorePreviewsOnStartup: ${tasksWithPreviews.length} tasks with previews out of ${completedTasks.length} completed`);
280
+
281
+ for (const preview of Object.values(state.previews ?? {})) {
282
+ const task = state.tasks[preview.taskId];
283
+ const deliverable = task?.resultContract?.deliverables?.[preview.deliverableIndex];
284
+ if (!task || task.status !== "completed" || !deliverable || !PreviewManager.isPreviewableDeliverable(deliverable)) {
285
+ await this.removePreview(preview.id, "preview can no longer be restored from task state");
286
+ }
287
+ }
288
+ for (const task of Object.values(state.tasks)) {
289
+ if (task.status !== "completed" || !task.resultContract) {
290
+ continue;
291
+ }
292
+ if (!task.resultContract.deliverables.some((d) => PreviewManager.isPreviewableDeliverable(d))) {
293
+ continue;
294
+ }
295
+ try {
296
+ await this.syncTaskPreviews(task.id);
297
+ } catch (err) {
298
+ this.deps.logger.warn(`Controller: failed to restore preview(s) for ${task.id}: ${String(err)}`);
299
+ }
300
+ }
301
+ this.startHealthMonitor();
302
+ }
303
+
304
+ async stopAll(reason = "controller shutdown"): Promise<void> {
305
+ this.stopHealthMonitor();
306
+ const previewIds = Array.from(new Set([
307
+ ...this.processes.keys(),
308
+ ...Object.keys(this.deps.getTeamState()?.previews ?? {}),
309
+ ]));
310
+ for (const previewId of previewIds) {
311
+ await this.stopPreviewProcess(previewId, reason, { removeRecord: false, clearLiveUrl: false });
312
+ }
313
+ }
314
+
315
+ private async collectPreviewSpecs(taskId: string, contract: WorkerTaskResultContract): Promise<DynamicPreviewSpec[]> {
316
+ const workspaceRoot = resolveTeamClawAgentWorkspaceRootDir();
317
+ const specs: DynamicPreviewSpec[] = [];
318
+
319
+ for (const [deliverableIndex, deliverable] of contract.deliverables.entries()) {
320
+ if (deliverable.artifactType !== "web-app" && deliverable.artifactType !== "static-site" && deliverable.artifactType !== "rest-api") {
321
+ continue;
322
+ }
323
+ const rawCommand = normalizeOptionalText(deliverable.previewCommand) ?? GENERIC_SERVE_COMMAND;
324
+ const previewCwd = normalizePreviewCwd(deliverable);
325
+ if (!previewCwd) {
326
+ this.deps.logger.warn(
327
+ `Controller: skipping preview for task ${taskId} deliverable[${deliverableIndex}] — cannot resolve previewCwd from value "${deliverable.value}"`,
328
+ );
329
+ continue;
330
+ }
331
+
332
+ // Resolve the actual working directory for file-system detection
333
+ const resolvedCwd = resolveWorkspaceDirectory(workspaceRoot, previewCwd);
334
+ const previewCommand = resolvedCwd
335
+ ? await resolveSmartPreviewCommand(resolvedCwd, rawCommand)
336
+ : rawCommand;
337
+
338
+ specs.push({
339
+ previewId: buildPreviewId(taskId, deliverableIndex),
340
+ taskId,
341
+ deliverableIndex,
342
+ deliverableValue: deliverable.value,
343
+ previewCommand,
344
+ previewCwd,
345
+ previewReadyPath: normalizePreviewReadyPath(deliverable.previewReadyPath),
346
+ liveUrl: buildPreviewLiveUrl(buildPreviewId(taskId, deliverableIndex)),
347
+ });
348
+ }
349
+ return specs;
350
+ }
351
+
352
+ private async ensurePreview(spec: DynamicPreviewSpec): Promise<void> {
353
+ const current = this.deps.getTeamState()?.previews?.[spec.previewId];
354
+ this.setTaskDeliverableLiveUrl(spec.taskId, spec.deliverableIndex, spec.liveUrl);
355
+
356
+ const isSameConfiguration = current
357
+ && current.previewCommand === spec.previewCommand
358
+ && current.previewCwd === spec.previewCwd
359
+ && current.previewReadyPath === spec.previewReadyPath
360
+ && current.deliverableValue === spec.deliverableValue;
361
+
362
+ if (
363
+ isSameConfiguration
364
+ && this.processes.has(spec.previewId)
365
+ && (current?.status === "healthy" || current?.status === "launching")
366
+ ) {
367
+ return;
368
+ }
369
+
370
+ if (current || this.processes.has(spec.previewId)) {
371
+ await this.stopPreviewProcess(spec.previewId, "preview configuration refreshed", {
372
+ removeRecord: false,
373
+ clearLiveUrl: false,
374
+ });
375
+ }
376
+ const targetPort = await this.reserveEphemeralPort();
377
+ const now = Date.now();
378
+ const record: DynamicPreviewRecord = {
379
+ id: spec.previewId,
380
+ taskId: spec.taskId,
381
+ deliverableIndex: spec.deliverableIndex,
382
+ deliverableValue: spec.deliverableValue,
383
+ previewCommand: spec.previewCommand,
384
+ previewCwd: spec.previewCwd,
385
+ previewReadyPath: spec.previewReadyPath,
386
+ liveUrl: spec.liveUrl,
387
+ targetPort,
388
+ status: "launching",
389
+ createdAt: current?.createdAt ?? now,
390
+ updatedAt: now,
391
+ };
392
+
393
+ this.deps.updateTeamState((state) => {
394
+ if (!state.previews) {
395
+ state.previews = {};
396
+ }
397
+ state.previews[spec.previewId] = record;
398
+ const task = state.tasks[spec.taskId];
399
+ const deliverable = task?.resultContract?.deliverables?.[spec.deliverableIndex];
400
+ if (deliverable) {
401
+ deliverable.liveUrl = spec.liveUrl;
402
+ }
403
+ });
404
+
405
+ await this.launchPreview(record);
406
+ }
407
+
408
+ private async launchPreview(record: DynamicPreviewRecord): Promise<void> {
409
+ const workspaceRoot = resolveTeamClawAgentWorkspaceRootDir();
410
+ const cwd = resolveWorkspaceDirectory(workspaceRoot, record.previewCwd);
411
+ if (!cwd) {
412
+ this.markPreviewFailed(record.id, `Dynamic preview path must stay inside the workspace: ${record.previewCwd}`);
413
+ return;
414
+ }
415
+
416
+ try {
417
+ await fs.access(cwd, fs.constants.R_OK);
418
+ } catch {
419
+ this.markPreviewFailed(record.id, `Dynamic preview cwd does not exist or is not readable: ${cwd}`);
420
+ return;
421
+ }
422
+
423
+ // Sanitize preview command: strip redundant `cd <path> &&` prefix since cwd is already set
424
+ let sanitizedCommand = record.previewCommand;
425
+ sanitizedCommand = sanitizedCommand.replace(/^\s*cd\s+\S+\s*&&\s*/u, "");
426
+ // Strip `source .../activate &&` and `venv setup &&` — we handle venv via PATH below
427
+ sanitizedCommand = sanitizedCommand.replace(/\bsource\s+\S*activate\s*&&\s*/gu, "");
428
+ sanitizedCommand = sanitizedCommand.replace(/\bpython3?\s+-m\s+venv\s+\S+\s*&&\s*/gu, "");
429
+ sanitizedCommand = sanitizedCommand.replace(/\bpip\s+install\s+[^&]+&&\s*/gu, "");
430
+
431
+ const resolvedCommand = sanitizedCommand
432
+ .replace(/\{PORT\}/gu, String(record.targetPort));
433
+
434
+ // Auto-detect Python venv and prepend to PATH
435
+ const extraEnv: Record<string, string> = {};
436
+ const venvNames = ["venv", ".venv", "env"];
437
+ for (const vn of venvNames) {
438
+ const venvBin = path.join(cwd, vn, "bin");
439
+ try {
440
+ await fs.access(path.join(venvBin, "python"), fs.constants.X_OK);
441
+ extraEnv.PATH = `${venvBin}:${process.env.PATH ?? ""}`;
442
+ extraEnv.VIRTUAL_ENV = path.join(cwd, vn);
443
+ this.deps.logger.info(`Controller: using Python venv at ${venvBin} for preview ${record.id}`);
444
+ break;
445
+ } catch {
446
+ // no venv here
447
+ }
448
+ }
449
+
450
+ // Pre-install Python dependencies if requirements.txt exists and venv is available
451
+ if (extraEnv.VIRTUAL_ENV) {
452
+ const reqPath = path.join(cwd, "requirements.txt");
453
+ try {
454
+ await fs.access(reqPath, fs.constants.R_OK);
455
+ const pipBin = path.join(extraEnv.VIRTUAL_ENV, "bin", "pip");
456
+ const pipInstall = spawnManagedCommandProcess({
457
+ command: `${pipBin} install -q -r requirements.txt`,
458
+ cwd,
459
+ env: { ...process.env, ...extraEnv },
460
+ });
461
+ await new Promise<void>((resolve) => {
462
+ pipInstall.on("exit", () => resolve());
463
+ pipInstall.on("error", () => resolve());
464
+ setTimeout(() => resolve(), 60_000);
465
+ });
466
+ } catch {
467
+ // no requirements.txt or pip failed — proceed anyway
468
+ }
469
+ }
470
+
471
+ const child = spawnManagedCommandProcess({
472
+ command: resolvedCommand,
473
+ cwd,
474
+ env: {
475
+ ...process.env,
476
+ ...extraEnv,
477
+ HOST: "0.0.0.0",
478
+ PORT: String(record.targetPort),
479
+ TEAMCLAW_PREVIEW_BASE_PATH: record.liveUrl.replace(/\/$/u, ""),
480
+ TEAMCLAW_PREVIEW_URL: record.liveUrl,
481
+ },
482
+ });
483
+ this.processes.set(record.id, child);
484
+ this.deps.updateTeamState((state) => {
485
+ const preview = state.previews?.[record.id];
486
+ if (!preview) {
487
+ return;
488
+ }
489
+ preview.pid = child.pid ?? undefined;
490
+ preview.status = "launching";
491
+ preview.updatedAt = Date.now();
492
+ delete preview.lastError;
493
+ });
494
+
495
+ let settled = false;
496
+ const finish = async (status: "healthy" | "failed", errorMessage?: string) => {
497
+ if (settled) {
498
+ return;
499
+ }
500
+ settled = true;
501
+ clearInterval(healthTimer);
502
+ clearTimeout(launchTimeout);
503
+ if (status === "healthy") {
504
+ this.markPreviewHealthy(record.id);
505
+ } else {
506
+ this.markPreviewFailed(record.id, errorMessage ?? "Preview launch failed");
507
+ }
508
+ };
509
+
510
+ const healthTimer = setInterval(() => {
511
+ void this.checkPreviewHealth(record)
512
+ .then((healthy) => {
513
+ if (healthy) {
514
+ void finish("healthy");
515
+ }
516
+ })
517
+ .catch(() => {
518
+ // keep polling until timeout or exit
519
+ });
520
+ }, PREVIEW_HEALTH_INTERVAL_MS);
521
+ healthTimer.unref?.();
522
+
523
+ const launchTimeout = setTimeout(() => {
524
+ void finish(
525
+ "failed",
526
+ `Dynamic preview did not become ready within ${Math.round(PREVIEW_LAUNCH_TIMEOUT_MS / 1000)} seconds.`,
527
+ ).finally(() => {
528
+ void this.stopPreviewProcess(record.id, "dynamic preview launch timed out", {
529
+ removeRecord: false,
530
+ clearLiveUrl: false,
531
+ nextStatus: "failed",
532
+ lastError: `Dynamic preview did not become ready within ${Math.round(PREVIEW_LAUNCH_TIMEOUT_MS / 1000)} seconds.`,
533
+ });
534
+ });
535
+ }, PREVIEW_LAUNCH_TIMEOUT_MS);
536
+ launchTimeout.unref?.();
537
+
538
+ child.once("exit", (code, signal) => {
539
+ clearInterval(healthTimer);
540
+ clearTimeout(launchTimeout);
541
+ this.processes.delete(record.id);
542
+ if (this.stoppingPreviewIds.has(record.id)) {
543
+ this.stoppingPreviewIds.delete(record.id);
544
+ return;
545
+ }
546
+ if (!settled) {
547
+ void finish(
548
+ "failed",
549
+ `Dynamic preview exited before readiness (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
550
+ );
551
+ return;
552
+ }
553
+ this.markPreviewFailed(
554
+ record.id,
555
+ `Dynamic preview process exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
556
+ );
557
+ });
558
+ }
559
+
560
+ private async removePreview(previewId: string, reason: string): Promise<void> {
561
+ await this.stopPreviewProcess(previewId, reason, {
562
+ removeRecord: true,
563
+ clearLiveUrl: true,
564
+ });
565
+ }
566
+
567
+ private async stopPreviewProcess(
568
+ previewId: string,
569
+ reason: string,
570
+ options: {
571
+ removeRecord: boolean;
572
+ clearLiveUrl: boolean;
573
+ nextStatus?: DynamicPreviewRecord["status"];
574
+ lastError?: string;
575
+ },
576
+ ): Promise<void> {
577
+ const child = this.processes.get(previewId);
578
+ if (child) {
579
+ this.stoppingPreviewIds.add(previewId);
580
+ this.processes.delete(previewId);
581
+ await stopManagedProcess(child, PREVIEW_STOP_TIMEOUT_MS).catch((err) => {
582
+ this.deps.logger.warn(`Controller: failed to stop preview ${previewId}: ${String(err)}`);
583
+ });
584
+ this.stoppingPreviewIds.delete(previewId);
585
+ }
586
+
587
+ this.deps.updateTeamState((state) => {
588
+ const preview = state.previews?.[previewId];
589
+ if (!preview) {
590
+ return;
591
+ }
592
+ const task = state.tasks[preview.taskId];
593
+ const deliverable = task?.resultContract?.deliverables?.[preview.deliverableIndex];
594
+ if (deliverable && options.clearLiveUrl && deliverable.liveUrl === preview.liveUrl) {
595
+ delete deliverable.liveUrl;
596
+ }
597
+ if (options.removeRecord) {
598
+ delete state.previews?.[previewId];
599
+ return;
600
+ }
601
+ preview.status = options.nextStatus ?? "stopped";
602
+ preview.updatedAt = Date.now();
603
+ preview.lastError = options.lastError ?? reason;
604
+ delete preview.pid;
605
+ });
606
+ }
607
+
608
+ private setTaskDeliverableLiveUrl(taskId: string, deliverableIndex: number, liveUrl: string): void {
609
+ this.deps.updateTeamState((state) => {
610
+ const task = state.tasks[taskId];
611
+ const deliverable = task?.resultContract?.deliverables?.[deliverableIndex];
612
+ if (!deliverable || deliverable.liveUrl === liveUrl) {
613
+ return;
614
+ }
615
+ deliverable.liveUrl = liveUrl;
616
+ task.updatedAt = Date.now();
617
+ });
618
+ }
619
+
620
+ private markPreviewHealthy(previewId: string): void {
621
+ this.deps.updateTeamState((state) => {
622
+ const preview = state.previews?.[previewId];
623
+ if (!preview) {
624
+ return;
625
+ }
626
+ preview.status = "healthy";
627
+ preview.updatedAt = Date.now();
628
+ preview.lastHealthCheckAt = Date.now();
629
+ delete preview.lastError;
630
+ });
631
+ }
632
+
633
+ private markPreviewFailed(previewId: string, errorMessage: string): void {
634
+ this.deps.updateTeamState((state) => {
635
+ const preview = state.previews?.[previewId];
636
+ if (!preview) {
637
+ return;
638
+ }
639
+ preview.status = "failed";
640
+ preview.updatedAt = Date.now();
641
+ preview.lastError = errorMessage;
642
+ delete preview.pid;
643
+ });
644
+ }
645
+
646
+ private async checkPreviewHealth(record: DynamicPreviewRecord): Promise<boolean> {
647
+ try {
648
+ const response = await fetch(`http://127.0.0.1:${record.targetPort}${record.previewReadyPath}`, {
649
+ method: "GET",
650
+ redirect: "manual",
651
+ signal: AbortSignal.timeout(5_000),
652
+ });
653
+ return response.status < 500;
654
+ } catch {
655
+ return false;
656
+ }
657
+ }
658
+
659
+ private async reserveEphemeralPort(): Promise<number> {
660
+ return await new Promise<number>((resolve, reject) => {
661
+ const server = net.createServer();
662
+ server.listen(0, "127.0.0.1", () => {
663
+ const address = server.address();
664
+ const port = typeof address === "object" && address ? address.port : 0;
665
+ server.close((err) => {
666
+ if (err) {
667
+ reject(err);
668
+ return;
669
+ }
670
+ resolve(port);
671
+ });
672
+ });
673
+ server.on("error", reject);
674
+ });
675
+ }
676
+ }