@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.
- package/README.md +52 -8
- package/cli.mjs +538 -224
- package/index.ts +76 -27
- package/openclaw.plugin.json +53 -28
- package/package.json +5 -2
- package/skills/teamclaw/SKILL.md +213 -0
- package/skills/teamclaw/references/api-quick-ref.md +117 -0
- package/skills/teamclaw-setup/SKILL.md +81 -0
- package/skills/teamclaw-setup/references/install-modes.md +136 -0
- package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
- package/src/config.ts +44 -16
- package/src/controller/controller-capacity.ts +2 -2
- package/src/controller/controller-service.ts +193 -47
- package/src/controller/controller-tools.ts +102 -2
- package/src/controller/delivery-report.ts +563 -0
- package/src/controller/http-server.ts +1907 -172
- package/src/controller/kickoff-orchestrator.ts +292 -0
- package/src/controller/managed-gateway-process.ts +330 -0
- package/src/controller/orchestration-manifest.ts +69 -1
- package/src/controller/preview-manager.ts +676 -0
- package/src/controller/prompt-injector.ts +116 -67
- package/src/controller/role-inference.ts +41 -0
- package/src/controller/websocket.ts +3 -1
- package/src/controller/worker-provisioning.ts +429 -74
- package/src/discovery.ts +1 -1
- package/src/git-collaboration.ts +198 -47
- package/src/identity.ts +12 -2
- package/src/interaction-contracts.ts +179 -3
- package/src/networking.ts +99 -0
- package/src/openclaw-workspace.ts +478 -11
- package/src/prompt-policy.ts +381 -0
- package/src/roles.ts +37 -36
- package/src/state.ts +40 -1
- package/src/task-executor.ts +282 -78
- package/src/types.ts +150 -7
- package/src/ui/app.js +1403 -175
- package/src/ui/assets/teamclaw-app-icon.png +0 -0
- package/src/ui/index.html +122 -40
- package/src/ui/style.css +829 -143
- package/src/worker/http-handler.ts +40 -4
- package/src/worker/prompt-injector.ts +9 -38
- package/src/worker/skill-installer.ts +2 -2
- package/src/worker/tools.ts +31 -5
- package/src/worker/worker-service.ts +49 -8
- package/src/workspace-browser.ts +20 -7
- 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
|
+
}
|