@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
|
@@ -12,14 +12,22 @@ import type { PluginLogger } from "../../api.js";
|
|
|
12
12
|
import { generateId } from "../protocol.js";
|
|
13
13
|
import {
|
|
14
14
|
resolveDefaultOpenClawConfigPath,
|
|
15
|
+
resolveDefaultOpenClawStateDir,
|
|
15
16
|
resolveDefaultTeamClawRuntimeRootDir,
|
|
17
|
+
resolveDefaultAgentDir,
|
|
18
|
+
resolveTeamClawAgentDir,
|
|
19
|
+
resolveTeamClawAgentWorkspaceRootDir,
|
|
20
|
+
resolveTeamClawWorkspaceDir,
|
|
21
|
+
TEAMCLAW_AGENT_ID,
|
|
16
22
|
} from "../openclaw-workspace.js";
|
|
17
23
|
import { ROLES } from "../roles.js";
|
|
24
|
+
import { inferTaskRole } from "./role-inference.js";
|
|
18
25
|
import type {
|
|
19
26
|
PluginConfig,
|
|
20
27
|
ProvisionedWorkerRecord,
|
|
21
28
|
ProvisionedWorkerStatus,
|
|
22
29
|
RoleId,
|
|
30
|
+
StartupProvisioningReadiness,
|
|
23
31
|
TaskInfo,
|
|
24
32
|
TeamProvisioningState,
|
|
25
33
|
TeamState,
|
|
@@ -33,6 +41,8 @@ const DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR = "/app/extensions/teamclaw";
|
|
|
33
41
|
const PROVISIONING_RECORD_RETENTION_MS = 6 * 60 * 60 * 1000;
|
|
34
42
|
const PROVISIONING_FAILURE_COOLDOWN_MS = 30_000;
|
|
35
43
|
const PROCESS_TERMINATION_TIMEOUT_MS = 10_000;
|
|
44
|
+
const STARTUP_READINESS_POLL_INTERVAL_MS = 1_000;
|
|
45
|
+
const STARTUP_READINESS_RETRY_INTERVAL_MS = 5_000;
|
|
36
46
|
const DOCKER_API_VERSION = resolveDockerApiVersion();
|
|
37
47
|
|
|
38
48
|
export type WorkerProvisioningManagerDeps = {
|
|
@@ -40,6 +50,8 @@ export type WorkerProvisioningManagerDeps = {
|
|
|
40
50
|
logger: PluginLogger;
|
|
41
51
|
getTeamState: () => TeamState | null;
|
|
42
52
|
updateTeamState: (updater: (state: TeamState) => void) => TeamState;
|
|
53
|
+
/** Actual HTTP port once the controller server has bound (may differ from config.port). */
|
|
54
|
+
actualControllerPort?: number;
|
|
43
55
|
};
|
|
44
56
|
|
|
45
57
|
type LaunchSpec = {
|
|
@@ -49,6 +61,7 @@ type LaunchSpec = {
|
|
|
49
61
|
controllerUrl: string;
|
|
50
62
|
workerPort: number;
|
|
51
63
|
gatewayPort: number;
|
|
64
|
+
publishedHostPort?: number;
|
|
52
65
|
workspaceDir?: string;
|
|
53
66
|
env: Record<string, string>;
|
|
54
67
|
configJson: string;
|
|
@@ -68,7 +81,7 @@ interface WorkerProvisionerBackend {
|
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
export class WorkerProvisioningManager {
|
|
71
|
-
private
|
|
84
|
+
private deps: WorkerProvisioningManagerDeps;
|
|
72
85
|
private readonly backend: WorkerProvisionerBackend | null;
|
|
73
86
|
private baseConfigPromise: Promise<Record<string, unknown>> | null = null;
|
|
74
87
|
private reconcilePromise: Promise<void> | null = null;
|
|
@@ -80,6 +93,11 @@ export class WorkerProvisioningManager {
|
|
|
80
93
|
this.backend = createProvisionerBackend(deps.config, deps.logger);
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
/** Update the actual controller port after the HTTP server binds. */
|
|
97
|
+
setActualPort(port: number): void {
|
|
98
|
+
this.deps = { ...this.deps, actualControllerPort: port };
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
isEnabled(): boolean {
|
|
84
102
|
return this.backend !== null;
|
|
85
103
|
}
|
|
@@ -88,6 +106,12 @@ export class WorkerProvisioningManager {
|
|
|
88
106
|
return Boolean(this.deps.getTeamState()?.provisioning?.workers?.[workerId]);
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
/** Returns true when the worker was provisioned as a local process (shared filesystem). */
|
|
110
|
+
isSharedWorkspaceWorker(workerId: string): boolean {
|
|
111
|
+
const record = this.deps.getTeamState()?.provisioning?.workers?.[workerId];
|
|
112
|
+
return Boolean(record && record.provider === "process");
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
syncState(state: TeamState): boolean {
|
|
92
116
|
if (!this.backend) {
|
|
93
117
|
return false;
|
|
@@ -95,6 +119,23 @@ export class WorkerProvisioningManager {
|
|
|
95
119
|
return this.refreshProvisioningState(state, Date.now());
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
primeStartupReadiness(): void {
|
|
123
|
+
if (!this.backend || this.stopped) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const previousAttempts = this.deps.getTeamState()?.provisioning?.startupReadiness?.attempts ?? 0;
|
|
128
|
+
this.updateStartupReadiness({
|
|
129
|
+
status: "checking",
|
|
130
|
+
startedAt: now,
|
|
131
|
+
checkedAt: now,
|
|
132
|
+
attempts: previousAttempts,
|
|
133
|
+
requiredRoles: this.determineStartupReadinessRoles(),
|
|
134
|
+
readyWorkerIds: [],
|
|
135
|
+
message: "Controller started; waiting for startup provisioning warm-up.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
98
139
|
validateRegistration(
|
|
99
140
|
workerId: string,
|
|
100
141
|
role: RoleId,
|
|
@@ -207,6 +248,82 @@ export class WorkerProvisioningManager {
|
|
|
207
248
|
return this.reconcilePromise;
|
|
208
249
|
}
|
|
209
250
|
|
|
251
|
+
async runStartupReadinessCheck(): Promise<void> {
|
|
252
|
+
if (!this.backend || this.stopped) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const requiredRoles = this.determineStartupReadinessRoles();
|
|
257
|
+
const previousAttempts = this.deps.getTeamState()?.provisioning?.startupReadiness?.attempts ?? 0;
|
|
258
|
+
const startedAt = Date.now();
|
|
259
|
+
this.updateStartupReadiness({
|
|
260
|
+
status: "checking",
|
|
261
|
+
startedAt,
|
|
262
|
+
checkedAt: startedAt,
|
|
263
|
+
attempts: previousAttempts + 1,
|
|
264
|
+
requiredRoles,
|
|
265
|
+
readyWorkerIds: [],
|
|
266
|
+
message: `Warming startup workers for ${requiredRoles.join(", ")}.`,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await this.ensureStartupProvisioningPrerequisites();
|
|
271
|
+
await this.ensureWarmWorkersForRoles(requiredRoles, "startup readiness warmup");
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
274
|
+
this.updateStartupReadiness({
|
|
275
|
+
status: "degraded",
|
|
276
|
+
startedAt,
|
|
277
|
+
checkedAt: Date.now(),
|
|
278
|
+
attempts: previousAttempts + 1,
|
|
279
|
+
requiredRoles,
|
|
280
|
+
readyWorkerIds: [],
|
|
281
|
+
message,
|
|
282
|
+
});
|
|
283
|
+
this.deps.logger.warn(`Provisioner: startup readiness prerequisites failed: ${message}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const deadline = Date.now() + this.deps.config.workerProvisioningStartupTimeoutMs;
|
|
288
|
+
let lastRetryAt = 0;
|
|
289
|
+
while (!this.stopped && Date.now() < deadline) {
|
|
290
|
+
const readiness = this.collectRoleReadiness(requiredRoles);
|
|
291
|
+
if (readiness.missingRoles.length === 0) {
|
|
292
|
+
this.updateStartupReadiness({
|
|
293
|
+
status: "ready",
|
|
294
|
+
startedAt,
|
|
295
|
+
checkedAt: Date.now(),
|
|
296
|
+
attempts: previousAttempts + 1,
|
|
297
|
+
requiredRoles,
|
|
298
|
+
readyWorkerIds: readiness.readyWorkerIds,
|
|
299
|
+
message: `Startup provisioning ready with ${readiness.readyWorkerIds.length} warm worker(s).`,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (Date.now() - lastRetryAt >= STARTUP_READINESS_RETRY_INTERVAL_MS) {
|
|
305
|
+
lastRetryAt = Date.now();
|
|
306
|
+
await this.ensureWarmWorkersForRoles(readiness.missingRoles, "startup readiness retry");
|
|
307
|
+
await this.requestReconcile("startup readiness poll");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await sleep(STARTUP_READINESS_POLL_INTERVAL_MS);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const finalReadiness = this.collectRoleReadiness(requiredRoles);
|
|
314
|
+
const failureDetails = this.describeStartupReadinessFailure(finalReadiness.missingRoles);
|
|
315
|
+
this.updateStartupReadiness({
|
|
316
|
+
status: "degraded",
|
|
317
|
+
startedAt,
|
|
318
|
+
checkedAt: Date.now(),
|
|
319
|
+
attempts: previousAttempts + 1,
|
|
320
|
+
requiredRoles,
|
|
321
|
+
readyWorkerIds: finalReadiness.readyWorkerIds,
|
|
322
|
+
message: failureDetails,
|
|
323
|
+
});
|
|
324
|
+
this.deps.logger.warn(`Provisioner: startup readiness degraded: ${failureDetails}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
210
327
|
async stop(): Promise<void> {
|
|
211
328
|
if (!this.backend) {
|
|
212
329
|
return;
|
|
@@ -286,6 +403,38 @@ export class WorkerProvisioningManager {
|
|
|
286
403
|
await this.scaleDownIdleWorkers(now);
|
|
287
404
|
}
|
|
288
405
|
|
|
406
|
+
private determineStartupReadinessRoles(): RoleId[] {
|
|
407
|
+
const configured = this.deps.config.workerProvisioningRoles;
|
|
408
|
+
if (configured.length > 0) {
|
|
409
|
+
return [...new Set(configured)];
|
|
410
|
+
}
|
|
411
|
+
return ["developer"];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private async ensureStartupProvisioningPrerequisites(): Promise<void> {
|
|
415
|
+
await ensureWritableDirectory(resolveTeamClawAgentWorkspaceRootDir());
|
|
416
|
+
await ensureWritableDirectory(resolveTeamClawWorkspaceDir());
|
|
417
|
+
await ensureWritableDirectory(resolveDefaultTeamClawRuntimeRootDir());
|
|
418
|
+
if (this.deps.config.workerProvisioningType === "process") {
|
|
419
|
+
await ensureWritableDirectory(resolveTeamClawAgentDir());
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async ensureWarmWorkersForRoles(roles: RoleId[], reason: string): Promise<void> {
|
|
424
|
+
const state = this.deps.getTeamState();
|
|
425
|
+
for (const role of roles) {
|
|
426
|
+
if (this.hasActiveOrLaunchingWorkerForRole(state, role)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (this.hasRecentProvisioningFailure(role)) {
|
|
430
|
+
this.deps.logger.warn(`Provisioner: startup warmup skipped ${role} due to recent failure`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
this.deps.logger.info(`Provisioner: warming ${role} worker (${reason})`);
|
|
434
|
+
await this.launchWorker(role);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
289
438
|
private async expireStalledLaunches(now: number): Promise<void> {
|
|
290
439
|
const state = this.deps.getTeamState();
|
|
291
440
|
if (!state?.provisioning) {
|
|
@@ -355,6 +504,54 @@ export class WorkerProvisioningManager {
|
|
|
355
504
|
);
|
|
356
505
|
}
|
|
357
506
|
|
|
507
|
+
private hasActiveOrLaunchingWorkerForRole(state: TeamState | null, role: RoleId): boolean {
|
|
508
|
+
if (!state) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
const activeWorker = Object.values(state.workers).some((worker) =>
|
|
512
|
+
worker.role === role && worker.status !== "offline"
|
|
513
|
+
);
|
|
514
|
+
if (activeWorker) {
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
return Object.values(state.provisioning?.workers ?? {}).some((record) =>
|
|
518
|
+
record.role === role && (record.status === "launching" || record.status === "registered")
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private collectRoleReadiness(roles: RoleId[]): { readyWorkerIds: string[]; missingRoles: RoleId[] } {
|
|
523
|
+
const state = this.deps.getTeamState();
|
|
524
|
+
const readyWorkerIds: string[] = [];
|
|
525
|
+
const missingRoles: RoleId[] = [];
|
|
526
|
+
for (const role of roles) {
|
|
527
|
+
const worker = Object.values(state?.workers ?? {}).find((candidate) =>
|
|
528
|
+
candidate.role === role && (candidate.status === "idle" || candidate.status === "busy")
|
|
529
|
+
);
|
|
530
|
+
if (worker) {
|
|
531
|
+
readyWorkerIds.push(worker.id);
|
|
532
|
+
} else {
|
|
533
|
+
missingRoles.push(role);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { readyWorkerIds, missingRoles };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private describeStartupReadinessFailure(missingRoles: RoleId[]): string {
|
|
540
|
+
const records = Object.values(this.deps.getTeamState()?.provisioning?.workers ?? {});
|
|
541
|
+
const relevant = records.filter((record) => missingRoles.includes(record.role));
|
|
542
|
+
const detail = relevant
|
|
543
|
+
.map((record) => `${record.role}:${record.status}${record.lastError ? ` (${record.lastError})` : ""}`)
|
|
544
|
+
.join(", ");
|
|
545
|
+
const suffix = detail ? ` Latest records: ${detail}` : "";
|
|
546
|
+
return `Startup worker readiness failed for roles: ${missingRoles.join(", ")}.${suffix}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private updateStartupReadiness(readiness: StartupProvisioningReadiness): void {
|
|
550
|
+
this.deps.updateTeamState((state) => {
|
|
551
|
+
ensureProvisioningState(state).startupReadiness = readiness;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
358
555
|
private async launchWorker(role: RoleId): Promise<void> {
|
|
359
556
|
if (!this.backend) {
|
|
360
557
|
return;
|
|
@@ -373,6 +570,12 @@ export class WorkerProvisioningManager {
|
|
|
373
570
|
const gatewayPort = requiresDedicatedHostPorts
|
|
374
571
|
? await reserveEphemeralPort()
|
|
375
572
|
: DEFAULT_CONTAINER_GATEWAY_PORT;
|
|
573
|
+
// Docker bridge networks need a host port to publish so the controller
|
|
574
|
+
// (running on the host) can reach the worker container.
|
|
575
|
+
const needsPublishedPort =
|
|
576
|
+
this.backend.type === "docker" &&
|
|
577
|
+
!isDockerHostNetwork(this.deps.config.workerProvisioningDockerNetwork);
|
|
578
|
+
const publishedHostPort = needsPublishedPort ? await reserveEphemeralPort() : undefined;
|
|
376
579
|
const now = Date.now();
|
|
377
580
|
|
|
378
581
|
this.deps.updateTeamState((state) => {
|
|
@@ -403,6 +606,7 @@ export class WorkerProvisioningManager {
|
|
|
403
606
|
controllerUrl,
|
|
404
607
|
workerPort,
|
|
405
608
|
gatewayPort,
|
|
609
|
+
publishedHostPort,
|
|
406
610
|
workspaceDir: getConfiguredWorkerWorkspaceDir(workerConfig),
|
|
407
611
|
env: this.buildForwardedEnv(controllerUrl),
|
|
408
612
|
configJson: `${JSON.stringify(workerConfig, null, 2)}\n`,
|
|
@@ -539,29 +743,7 @@ export class WorkerProvisioningManager {
|
|
|
539
743
|
}
|
|
540
744
|
|
|
541
745
|
private inferTaskRole(task: TaskInfo): RoleId | null {
|
|
542
|
-
|
|
543
|
-
return task.assignedRole;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const text = `${task.title} ${task.description}`.toLowerCase();
|
|
547
|
-
let bestRole: RoleId | null = null;
|
|
548
|
-
let bestScore = 0;
|
|
549
|
-
|
|
550
|
-
for (const role of ROLES) {
|
|
551
|
-
const roleTokens = [
|
|
552
|
-
role.id,
|
|
553
|
-
role.label,
|
|
554
|
-
...role.capabilities,
|
|
555
|
-
].flatMap((entry) => entry.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 2));
|
|
556
|
-
const uniqueTokens = [...new Set(roleTokens)];
|
|
557
|
-
const score = uniqueTokens.reduce((count, token) => count + (text.includes(token) ? 1 : 0), 0);
|
|
558
|
-
if (score > bestScore) {
|
|
559
|
-
bestScore = score;
|
|
560
|
-
bestRole = role.id;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return bestScore > 0 ? bestRole : null;
|
|
746
|
+
return inferTaskRole(task);
|
|
565
747
|
}
|
|
566
748
|
|
|
567
749
|
private computeRoleDemand(state: TeamState, role: RoleId): number {
|
|
@@ -588,12 +770,15 @@ export class WorkerProvisioningManager {
|
|
|
588
770
|
}
|
|
589
771
|
|
|
590
772
|
private resolveControllerUrl(): string {
|
|
773
|
+
if (this.backend?.type === "process") {
|
|
774
|
+
// Process workers run on the same host — always use loopback regardless
|
|
775
|
+
// of workerProvisioningControllerUrl (which may target Docker DNS).
|
|
776
|
+
const port = this.deps.actualControllerPort ?? this.deps.config.port;
|
|
777
|
+
return `http://127.0.0.1:${port}`;
|
|
778
|
+
}
|
|
591
779
|
if (this.deps.config.workerProvisioningControllerUrl) {
|
|
592
780
|
return this.deps.config.workerProvisioningControllerUrl;
|
|
593
781
|
}
|
|
594
|
-
if (this.backend?.type === "process") {
|
|
595
|
-
return `http://127.0.0.1:${this.deps.config.port}`;
|
|
596
|
-
}
|
|
597
782
|
throw new Error(
|
|
598
783
|
`workerProvisioningControllerUrl is required when workerProvisioningType=${this.backend?.type}`,
|
|
599
784
|
);
|
|
@@ -702,6 +887,7 @@ class ProcessProvisioner implements WorkerProvisionerBackend {
|
|
|
702
887
|
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
703
888
|
TEAMCLAW_WORKER_ID: spec.workerId,
|
|
704
889
|
TEAMCLAW_LAUNCH_TOKEN: spec.launchToken,
|
|
890
|
+
...(spec.workspaceDir ? { TEAMCLAW_WORKSPACE_DIR: spec.workspaceDir } : {}),
|
|
705
891
|
},
|
|
706
892
|
stdio: ["ignore", "pipe", "pipe"],
|
|
707
893
|
});
|
|
@@ -773,7 +959,7 @@ class DockerProvisioner implements WorkerProvisionerBackend {
|
|
|
773
959
|
}
|
|
774
960
|
|
|
775
961
|
const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
|
|
776
|
-
const env = {
|
|
962
|
+
const env: Record<string, string> = {
|
|
777
963
|
...spec.env,
|
|
778
964
|
HOME: "/home/node",
|
|
779
965
|
OPENCLAW_HOME: "/home/node",
|
|
@@ -786,25 +972,52 @@ class DockerProvisioner implements WorkerProvisionerBackend {
|
|
|
786
972
|
...(spec.workspaceDir ? { TEAMCLAW_WORKSPACE_DIR: spec.workspaceDir } : {}),
|
|
787
973
|
};
|
|
788
974
|
|
|
789
|
-
|
|
975
|
+
// When workers share a Docker network with the controller container, advertise
|
|
976
|
+
// the container name and internal worker port so sibling containers can reach it
|
|
977
|
+
// directly. When the controller runs on the host, fall back to publishing a host
|
|
978
|
+
// port and advertising localhost for host-side callbacks.
|
|
979
|
+
const usePortPublishing = spec.publishedHostPort !== undefined;
|
|
980
|
+
if (this.config.workerProvisioningDockerNetwork) {
|
|
981
|
+
env.TEAMCLAW_ADVERTISE_HOST = instanceName;
|
|
982
|
+
env.TEAMCLAW_ADVERTISE_PORT = String(spec.workerPort);
|
|
983
|
+
} else if (usePortPublishing) {
|
|
984
|
+
env.TEAMCLAW_ADVERTISE_HOST = "localhost";
|
|
985
|
+
env.TEAMCLAW_ADVERTISE_PORT = String(spec.publishedHostPort);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const hostConfig: Record<string, unknown> = {
|
|
989
|
+
Binds: buildDockerBinds(this.config),
|
|
990
|
+
NetworkMode: this.config.workerProvisioningDockerNetwork || undefined,
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
if (usePortPublishing) {
|
|
994
|
+
hostConfig.PortBindings = {
|
|
995
|
+
[`${spec.workerPort}/tcp`]: [{ HostPort: String(spec.publishedHostPort) }],
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const containerConfig: Record<string, unknown> = {
|
|
1000
|
+
Image: this.config.workerProvisioningImage,
|
|
1001
|
+
Cmd: ["sh", "-lc", buildContainerBootstrapScript()],
|
|
1002
|
+
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
|
1003
|
+
User: "root",
|
|
1004
|
+
Labels: {
|
|
1005
|
+
"teamclaw.managed": "true",
|
|
1006
|
+
"teamclaw.team": this.config.teamName,
|
|
1007
|
+
"teamclaw.role": spec.role,
|
|
1008
|
+
"teamclaw.worker_id": spec.workerId,
|
|
1009
|
+
},
|
|
1010
|
+
HostConfig: hostConfig,
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
if (usePortPublishing) {
|
|
1014
|
+
containerConfig.ExposedPorts = { [`${spec.workerPort}/tcp`]: {} };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
790
1017
|
const response = await this.client.requestJson<{ Id?: string }>(
|
|
791
1018
|
"POST",
|
|
792
1019
|
`/containers/create?name=${encodeURIComponent(instanceName)}`,
|
|
793
|
-
|
|
794
|
-
Image: this.config.workerProvisioningImage,
|
|
795
|
-
Cmd: ["sh", "-lc", script],
|
|
796
|
-
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
|
797
|
-
Labels: {
|
|
798
|
-
"teamclaw.managed": "true",
|
|
799
|
-
"teamclaw.team": this.config.teamName,
|
|
800
|
-
"teamclaw.role": spec.role,
|
|
801
|
-
"teamclaw.worker_id": spec.workerId,
|
|
802
|
-
},
|
|
803
|
-
HostConfig: {
|
|
804
|
-
Binds: buildDockerBinds(this.config),
|
|
805
|
-
NetworkMode: this.config.workerProvisioningDockerNetwork || undefined,
|
|
806
|
-
},
|
|
807
|
-
},
|
|
1020
|
+
containerConfig,
|
|
808
1021
|
[201],
|
|
809
1022
|
);
|
|
810
1023
|
|
|
@@ -831,14 +1044,109 @@ class DockerProvisioner implements WorkerProvisionerBackend {
|
|
|
831
1044
|
}
|
|
832
1045
|
}
|
|
833
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* Lightweight Kubernetes API client using in-cluster service account or
|
|
1049
|
+
* kubeconfig for out-of-cluster (via kubectl proxy or direct API).
|
|
1050
|
+
* Avoids requiring kubectl binary in the container image.
|
|
1051
|
+
*/
|
|
1052
|
+
class K8sApiClient {
|
|
1053
|
+
private token: string | undefined;
|
|
1054
|
+
private caCert: Buffer | undefined;
|
|
1055
|
+
private apiServer: string;
|
|
1056
|
+
private contextArgs: string;
|
|
1057
|
+
|
|
1058
|
+
constructor(context: string, private readonly logger: PluginLogger) {
|
|
1059
|
+
this.contextArgs = context;
|
|
1060
|
+
// In-cluster detection: K8s injects these env vars and mounts SA token
|
|
1061
|
+
const host = process.env.KUBERNETES_SERVICE_HOST;
|
|
1062
|
+
const port = process.env.KUBERNETES_SERVICE_PORT;
|
|
1063
|
+
if (host && port) {
|
|
1064
|
+
this.apiServer = `https://${host}:${port}`;
|
|
1065
|
+
try {
|
|
1066
|
+
const fs = require("node:fs");
|
|
1067
|
+
this.token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim();
|
|
1068
|
+
this.caCert = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
|
1069
|
+
} catch {
|
|
1070
|
+
logger.warn("K8sApiClient: in-cluster but cannot read service account token — falling back to kubectl");
|
|
1071
|
+
}
|
|
1072
|
+
} else {
|
|
1073
|
+
// Out-of-cluster: delegate to kubectl
|
|
1074
|
+
this.apiServer = "";
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private get inCluster(): boolean {
|
|
1079
|
+
return Boolean(this.apiServer && this.token);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async createPod(namespace: string, manifest: unknown): Promise<void> {
|
|
1083
|
+
if (this.inCluster) {
|
|
1084
|
+
await this.apiRequest("POST", `/api/v1/namespaces/${namespace}/pods`, manifest, [200, 201]);
|
|
1085
|
+
} else {
|
|
1086
|
+
await runCommand("kubectl", [
|
|
1087
|
+
...buildKubectlContextArgs(this.contextArgs),
|
|
1088
|
+
"apply", "-f", "-",
|
|
1089
|
+
], JSON.stringify(manifest));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async deletePod(namespace: string, podName: string): Promise<void> {
|
|
1094
|
+
if (this.inCluster) {
|
|
1095
|
+
await this.apiRequest("DELETE", `/api/v1/namespaces/${namespace}/pods/${podName}?gracePeriodSeconds=0`, undefined, [200, 202, 404]);
|
|
1096
|
+
} else {
|
|
1097
|
+
await runCommand("kubectl", [
|
|
1098
|
+
...buildKubectlContextArgs(this.contextArgs),
|
|
1099
|
+
"-n", namespace,
|
|
1100
|
+
"delete", "pod", podName,
|
|
1101
|
+
"--ignore-not-found=true", "--grace-period=0", "--force",
|
|
1102
|
+
]);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private async apiRequest(method: string, apiPath: string, body: unknown, okStatuses: number[]): Promise<string> {
|
|
1107
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
1108
|
+
return new Promise<string>((resolve, reject) => {
|
|
1109
|
+
const url = new URL(apiPath, this.apiServer);
|
|
1110
|
+
const options: https.RequestOptions = {
|
|
1111
|
+
method,
|
|
1112
|
+
hostname: url.hostname,
|
|
1113
|
+
port: url.port,
|
|
1114
|
+
path: url.pathname + url.search,
|
|
1115
|
+
headers: {
|
|
1116
|
+
"Authorization": `Bearer ${this.token}`,
|
|
1117
|
+
...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
|
|
1118
|
+
},
|
|
1119
|
+
...(this.caCert ? { ca: this.caCert } : { rejectUnauthorized: false }),
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const req = https.request(options, (res) => {
|
|
1123
|
+
let data = "";
|
|
1124
|
+
res.on("data", (chunk: string) => { data += chunk; });
|
|
1125
|
+
res.on("end", () => {
|
|
1126
|
+
if (okStatuses.includes(res.statusCode ?? 0)) {
|
|
1127
|
+
resolve(data);
|
|
1128
|
+
} else {
|
|
1129
|
+
reject(new Error(`K8s API ${method} ${apiPath} returned ${res.statusCode}: ${data.slice(0, 500)}`));
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
req.on("error", reject);
|
|
1134
|
+
if (payload) req.write(payload);
|
|
1135
|
+
req.end();
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
834
1140
|
class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
835
1141
|
readonly type = "kubernetes" as const;
|
|
836
1142
|
private readonly config: PluginConfig;
|
|
837
1143
|
private readonly logger: PluginLogger;
|
|
1144
|
+
private readonly k8sApi: K8sApiClient;
|
|
838
1145
|
|
|
839
1146
|
constructor(config: PluginConfig, logger: PluginLogger) {
|
|
840
1147
|
this.config = config;
|
|
841
1148
|
this.logger = logger;
|
|
1149
|
+
this.k8sApi = new K8sApiClient(config.workerProvisioningKubernetesContext, logger);
|
|
842
1150
|
}
|
|
843
1151
|
|
|
844
1152
|
async launch(spec: LaunchSpec): Promise<LaunchResult> {
|
|
@@ -847,6 +1155,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
|
847
1155
|
}
|
|
848
1156
|
|
|
849
1157
|
const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
|
|
1158
|
+
const ns = this.config.workerProvisioningKubernetesNamespace;
|
|
850
1159
|
const env = {
|
|
851
1160
|
...spec.env,
|
|
852
1161
|
HOME: "/home/node",
|
|
@@ -863,13 +1172,17 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
|
863
1172
|
const hasPersistentWorkspace = Boolean(
|
|
864
1173
|
workspaceRoot && this.config.workerProvisioningKubernetesWorkspacePersistentVolumeClaim,
|
|
865
1174
|
);
|
|
1175
|
+
const imagePullSecrets = this.config.workerProvisioningKubernetesImagePullSecrets
|
|
1176
|
+
.map((name) => name.trim())
|
|
1177
|
+
.filter((name) => name.length > 0)
|
|
1178
|
+
.map((name) => ({ name }));
|
|
866
1179
|
|
|
867
1180
|
const manifest = {
|
|
868
1181
|
apiVersion: "v1",
|
|
869
1182
|
kind: "Pod",
|
|
870
1183
|
metadata: {
|
|
871
1184
|
name: instanceName,
|
|
872
|
-
namespace:
|
|
1185
|
+
namespace: ns,
|
|
873
1186
|
labels: {
|
|
874
1187
|
app: "teamclaw-worker",
|
|
875
1188
|
"teamclaw.managed": "true",
|
|
@@ -885,6 +1198,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
|
885
1198
|
restartPolicy: "Never",
|
|
886
1199
|
hostname: buildManagedHostname(this.config.teamName, spec.role, spec.workerId),
|
|
887
1200
|
serviceAccountName: this.config.workerProvisioningKubernetesServiceAccount || undefined,
|
|
1201
|
+
imagePullSecrets: imagePullSecrets.length > 0 ? imagePullSecrets : undefined,
|
|
888
1202
|
securityContext: hasPersistentWorkspace
|
|
889
1203
|
? {
|
|
890
1204
|
runAsUser: 1000,
|
|
@@ -922,16 +1236,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
|
922
1236
|
},
|
|
923
1237
|
};
|
|
924
1238
|
|
|
925
|
-
await
|
|
926
|
-
"kubectl",
|
|
927
|
-
[
|
|
928
|
-
...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
|
|
929
|
-
"apply",
|
|
930
|
-
"-f",
|
|
931
|
-
"-",
|
|
932
|
-
],
|
|
933
|
-
JSON.stringify(manifest),
|
|
934
|
-
);
|
|
1239
|
+
await this.k8sApi.createPod(ns, manifest);
|
|
935
1240
|
this.logger.info(`Provisioner: applied kubernetes pod ${instanceName}`);
|
|
936
1241
|
|
|
937
1242
|
return {
|
|
@@ -945,18 +1250,8 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
|
945
1250
|
if (!podName) {
|
|
946
1251
|
return;
|
|
947
1252
|
}
|
|
948
|
-
|
|
949
|
-
await
|
|
950
|
-
...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
|
|
951
|
-
"-n",
|
|
952
|
-
this.config.workerProvisioningKubernetesNamespace,
|
|
953
|
-
"delete",
|
|
954
|
-
"pod",
|
|
955
|
-
podName,
|
|
956
|
-
"--ignore-not-found=true",
|
|
957
|
-
"--grace-period=0",
|
|
958
|
-
"--force",
|
|
959
|
-
]);
|
|
1253
|
+
const ns = this.config.workerProvisioningKubernetesNamespace;
|
|
1254
|
+
await this.k8sApi.deletePod(ns, podName);
|
|
960
1255
|
}
|
|
961
1256
|
}
|
|
962
1257
|
|
|
@@ -1084,7 +1379,13 @@ function buildProvisionedWorkerConfig(
|
|
|
1084
1379
|
|
|
1085
1380
|
const gateway = ensureRecord(config.gateway);
|
|
1086
1381
|
gateway.mode = "local";
|
|
1087
|
-
|
|
1382
|
+
// Workers running inside Docker/K8s containers must bind on all
|
|
1383
|
+
// interfaces so the controller (in a sibling container) can reach them.
|
|
1384
|
+
gateway.bind =
|
|
1385
|
+
controllerConfig.workerProvisioningType === "docker" ||
|
|
1386
|
+
controllerConfig.workerProvisioningType === "kubernetes"
|
|
1387
|
+
? "lan"
|
|
1388
|
+
: "loopback";
|
|
1088
1389
|
gateway.port = spec.gatewayPort;
|
|
1089
1390
|
delete gateway.remote;
|
|
1090
1391
|
config.gateway = gateway;
|
|
@@ -1099,6 +1400,7 @@ function buildProvisionedWorkerConfig(
|
|
|
1099
1400
|
teamclawEntry.enabled = true;
|
|
1100
1401
|
const teamclawConfig = ensureRecord(teamclawEntry.config);
|
|
1101
1402
|
teamclawConfig.mode = "worker";
|
|
1403
|
+
teamclawConfig.processModel = "multi";
|
|
1102
1404
|
teamclawConfig.role = spec.role;
|
|
1103
1405
|
teamclawConfig.port = spec.workerPort;
|
|
1104
1406
|
teamclawConfig.controllerUrl = spec.controllerUrl;
|
|
@@ -1110,7 +1412,6 @@ function buildProvisionedWorkerConfig(
|
|
|
1110
1412
|
teamclawConfig.gitDefaultBranch = controllerConfig.gitDefaultBranch;
|
|
1111
1413
|
teamclawConfig.gitAuthorName = controllerConfig.gitAuthorName;
|
|
1112
1414
|
teamclawConfig.gitAuthorEmail = controllerConfig.gitAuthorEmail;
|
|
1113
|
-
teamclawConfig.localRoles = [];
|
|
1114
1415
|
teamclawConfig.workerProvisioningType = "none";
|
|
1115
1416
|
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
1116
1417
|
teamclawConfig.workerProvisioningRoles = [];
|
|
@@ -1128,6 +1429,7 @@ function buildProvisionedWorkerConfig(
|
|
|
1128
1429
|
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
1129
1430
|
teamclawConfig.workerProvisioningKubernetesContext = "";
|
|
1130
1431
|
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
1432
|
+
teamclawConfig.workerProvisioningKubernetesImagePullSecrets = [];
|
|
1131
1433
|
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
1132
1434
|
teamclawConfig.workerProvisioningKubernetesLabels = {};
|
|
1133
1435
|
teamclawConfig.workerProvisioningKubernetesAnnotations = {};
|
|
@@ -1149,6 +1451,17 @@ function ensureProvisioningState(state: TeamState): TeamProvisioningState {
|
|
|
1149
1451
|
return state.provisioning;
|
|
1150
1452
|
}
|
|
1151
1453
|
|
|
1454
|
+
async function ensureWritableDirectory(dir: string): Promise<void> {
|
|
1455
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1456
|
+
const probePath = path.join(dir, `.teamclaw-write-probe-${process.pid}-${Date.now()}`);
|
|
1457
|
+
await fs.writeFile(probePath, "ok\n", "utf8");
|
|
1458
|
+
await fs.rm(probePath, { force: true });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function sleep(ms: number): Promise<void> {
|
|
1462
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1152
1465
|
async function loadOpenClawConfig(configPath: string): Promise<Record<string, unknown>> {
|
|
1153
1466
|
const raw = await fs.readFile(configPath, "utf8");
|
|
1154
1467
|
return parseLooseJsonObject(raw, configPath);
|
|
@@ -1159,11 +1472,45 @@ async function prepareProcessRuntimeExtensions(stateDir: string): Promise<void>
|
|
|
1159
1472
|
const controllerExtensionsDir = path.join(path.dirname(resolveDefaultOpenClawConfigPath()), "extensions");
|
|
1160
1473
|
if (await pathExists(controllerExtensionsDir)) {
|
|
1161
1474
|
await fs.symlink(controllerExtensionsDir, runtimeExtensionsDir, "dir");
|
|
1162
|
-
|
|
1475
|
+
} else {
|
|
1476
|
+
await fs.mkdir(runtimeExtensionsDir, { recursive: true });
|
|
1477
|
+
await fs.symlink(resolveCurrentTeamClawPluginRootDir(), path.join(runtimeExtensionsDir, "teamclaw"), "dir");
|
|
1163
1478
|
}
|
|
1164
1479
|
|
|
1165
|
-
await
|
|
1166
|
-
|
|
1480
|
+
await copyControllerAuthProfiles(stateDir);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
async function copyControllerAuthProfiles(stateDir: string): Promise<void> {
|
|
1484
|
+
const candidateControllerStateDirs = [
|
|
1485
|
+
path.dirname(resolveDefaultOpenClawConfigPath()),
|
|
1486
|
+
resolveDefaultOpenClawStateDir(),
|
|
1487
|
+
];
|
|
1488
|
+
const candidateAuthProfilePaths = [
|
|
1489
|
+
path.join(resolveTeamClawAgentDir(), "auth-profiles.json"),
|
|
1490
|
+
path.join(resolveDefaultAgentDir(), "auth-profiles.json"),
|
|
1491
|
+
...candidateControllerStateDirs.flatMap((controllerStateDir) => [
|
|
1492
|
+
path.join(controllerStateDir, "agents", TEAMCLAW_AGENT_ID, "agent", "auth-profiles.json"),
|
|
1493
|
+
path.join(controllerStateDir, "agents", "main", "agent", "auth-profiles.json"),
|
|
1494
|
+
]),
|
|
1495
|
+
].filter((value, index, values) => values.indexOf(value) === index);
|
|
1496
|
+
let controllerAuthProfilesPath: string | undefined;
|
|
1497
|
+
for (const candidatePath of candidateAuthProfilePaths) {
|
|
1498
|
+
if (await pathExists(candidatePath)) {
|
|
1499
|
+
controllerAuthProfilesPath = candidatePath;
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (!controllerAuthProfilesPath) {
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const runtimeAuthProfilePaths = [
|
|
1507
|
+
path.join(stateDir, "agents", TEAMCLAW_AGENT_ID, "agent", "auth-profiles.json"),
|
|
1508
|
+
path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"),
|
|
1509
|
+
];
|
|
1510
|
+
for (const runtimeAuthProfilesPath of runtimeAuthProfilePaths) {
|
|
1511
|
+
await fs.mkdir(path.dirname(runtimeAuthProfilesPath), { recursive: true });
|
|
1512
|
+
await fs.copyFile(controllerAuthProfilesPath, runtimeAuthProfilesPath);
|
|
1513
|
+
}
|
|
1167
1514
|
}
|
|
1168
1515
|
|
|
1169
1516
|
function cloneJson<T>(value: T): T {
|
|
@@ -1299,7 +1646,7 @@ function resolveGatewayEntrypoint(): string {
|
|
|
1299
1646
|
}
|
|
1300
1647
|
|
|
1301
1648
|
function resolveCurrentTeamClawPluginRootDir(): string {
|
|
1302
|
-
return path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
|
1649
|
+
return process.env.TEAMCLAW_BAKED_IN === "true" ? "" : path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
|
1303
1650
|
}
|
|
1304
1651
|
|
|
1305
1652
|
function attachChildLogs(child: ChildProcess, logger: PluginLogger, prefix: string): void {
|
|
@@ -1422,7 +1769,8 @@ function buildContainerBootstrapScript(): string {
|
|
|
1422
1769
|
function buildDockerBinds(config: PluginConfig): string[] {
|
|
1423
1770
|
const binds = [...config.workerProvisioningDockerMounts];
|
|
1424
1771
|
if (!binds.some((bind) => extractDockerBindTarget(bind) === DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR)) {
|
|
1425
|
-
|
|
1772
|
+
const _pd = resolveCurrentTeamClawPluginRootDir();
|
|
1773
|
+
if (_pd) binds.unshift(`${_pd}:${DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR}:ro`);
|
|
1426
1774
|
}
|
|
1427
1775
|
if (config.workerProvisioningDockerWorkspaceVolume && config.workerProvisioningWorkspaceRoot) {
|
|
1428
1776
|
binds.unshift(`${config.workerProvisioningDockerWorkspaceVolume}:${config.workerProvisioningWorkspaceRoot}`);
|
|
@@ -1436,6 +1784,13 @@ function buildProvisionedWorkspaceDir(
|
|
|
1436
1784
|
role: RoleId,
|
|
1437
1785
|
workerId: string,
|
|
1438
1786
|
): string {
|
|
1787
|
+
// Process-type workers run on the same host — share the controller's workspace
|
|
1788
|
+
// so that file artifacts (previews, deliverables) are immediately visible to
|
|
1789
|
+
// the controller without requiring git sync or file transfer.
|
|
1790
|
+
if (provider === "process") {
|
|
1791
|
+
return resolveTeamClawAgentWorkspaceRootDir();
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1439
1794
|
if (
|
|
1440
1795
|
(provider !== "docker" && provider !== "kubernetes") ||
|
|
1441
1796
|
!config.workerProvisioningWorkspaceRoot
|