@teamclaws/teamclaw 2026.3.21
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 +37 -0
- package/api.ts +10 -0
- package/index.ts +246 -0
- package/openclaw.plugin.json +41 -0
- package/package.json +63 -0
- package/src/config.ts +297 -0
- package/src/controller/controller-service.ts +197 -0
- package/src/controller/controller-tools.ts +224 -0
- package/src/controller/http-server.ts +1946 -0
- package/src/controller/local-worker-manager.ts +531 -0
- package/src/controller/message-router.ts +62 -0
- package/src/controller/prompt-injector.ts +116 -0
- package/src/controller/task-router.ts +97 -0
- package/src/controller/websocket.ts +63 -0
- package/src/controller/worker-provisioning.ts +1286 -0
- package/src/discovery.ts +101 -0
- package/src/git-collaboration.ts +690 -0
- package/src/identity.ts +149 -0
- package/src/openclaw-workspace.ts +101 -0
- package/src/protocol.ts +88 -0
- package/src/roles.ts +275 -0
- package/src/state.ts +118 -0
- package/src/task-executor.ts +478 -0
- package/src/types.ts +469 -0
- package/src/ui/app.js +1400 -0
- package/src/ui/index.html +207 -0
- package/src/ui/style.css +1281 -0
- package/src/worker/http-handler.ts +136 -0
- package/src/worker/message-queue.ts +31 -0
- package/src/worker/prompt-injector.ts +72 -0
- package/src/worker/tools.ts +318 -0
- package/src/worker/worker-service.ts +194 -0
- package/src/workspace-browser.ts +312 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import readline from "node:readline";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
|
+
import JSON5 from "json5";
|
|
11
|
+
import type { PluginLogger } from "../../api.js";
|
|
12
|
+
import { generateId } from "../protocol.js";
|
|
13
|
+
import { resolveDefaultOpenClawConfigPath } from "../openclaw-workspace.js";
|
|
14
|
+
import { ROLES } from "../roles.js";
|
|
15
|
+
import type {
|
|
16
|
+
PluginConfig,
|
|
17
|
+
ProvisionedWorkerRecord,
|
|
18
|
+
ProvisionedWorkerStatus,
|
|
19
|
+
RoleId,
|
|
20
|
+
TaskInfo,
|
|
21
|
+
TeamProvisioningState,
|
|
22
|
+
TeamState,
|
|
23
|
+
WorkerProvisioningType,
|
|
24
|
+
WorkerStatus,
|
|
25
|
+
} from "../types.js";
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CONTAINER_WORKER_PORT = 9527;
|
|
28
|
+
const DEFAULT_CONTAINER_GATEWAY_PORT = 18789;
|
|
29
|
+
const PROVISIONING_RECORD_RETENTION_MS = 6 * 60 * 60 * 1000;
|
|
30
|
+
const PROVISIONING_FAILURE_COOLDOWN_MS = 30_000;
|
|
31
|
+
const PROCESS_TERMINATION_TIMEOUT_MS = 10_000;
|
|
32
|
+
const DOCKER_API_VERSION = "v1.41";
|
|
33
|
+
|
|
34
|
+
export type WorkerProvisioningManagerDeps = {
|
|
35
|
+
config: PluginConfig;
|
|
36
|
+
logger: PluginLogger;
|
|
37
|
+
getTeamState: () => TeamState | null;
|
|
38
|
+
updateTeamState: (updater: (state: TeamState) => void) => TeamState;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type LaunchSpec = {
|
|
42
|
+
workerId: string;
|
|
43
|
+
role: RoleId;
|
|
44
|
+
launchToken: string;
|
|
45
|
+
controllerUrl: string;
|
|
46
|
+
workerPort: number;
|
|
47
|
+
gatewayPort: number;
|
|
48
|
+
env: Record<string, string>;
|
|
49
|
+
configJson: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type LaunchResult = {
|
|
53
|
+
instanceId?: string;
|
|
54
|
+
instanceName?: string;
|
|
55
|
+
runtimeHomeDir?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface WorkerProvisionerBackend {
|
|
59
|
+
readonly type: WorkerProvisioningType;
|
|
60
|
+
launch(spec: LaunchSpec): Promise<LaunchResult>;
|
|
61
|
+
terminate(record: ProvisionedWorkerRecord): Promise<void>;
|
|
62
|
+
stop?(): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class WorkerProvisioningManager {
|
|
66
|
+
private readonly deps: WorkerProvisioningManagerDeps;
|
|
67
|
+
private readonly backend: WorkerProvisionerBackend | null;
|
|
68
|
+
private baseConfigPromise: Promise<Record<string, unknown>> | null = null;
|
|
69
|
+
private reconcilePromise: Promise<void> | null = null;
|
|
70
|
+
private reconcileQueued = false;
|
|
71
|
+
private stopped = false;
|
|
72
|
+
|
|
73
|
+
constructor(deps: WorkerProvisioningManagerDeps) {
|
|
74
|
+
this.deps = deps;
|
|
75
|
+
this.backend = createProvisionerBackend(deps.config, deps.logger);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isEnabled(): boolean {
|
|
79
|
+
return this.backend !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
hasManagedWorker(workerId: string): boolean {
|
|
83
|
+
return Boolean(this.deps.getTeamState()?.provisioning?.workers?.[workerId]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
syncState(state: TeamState): boolean {
|
|
87
|
+
if (!this.backend) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return this.refreshProvisioningState(state, Date.now());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
validateRegistration(
|
|
94
|
+
workerId: string,
|
|
95
|
+
role: RoleId,
|
|
96
|
+
launchToken: string | undefined,
|
|
97
|
+
): { ok: boolean; managed: boolean; reason?: string } {
|
|
98
|
+
const record = this.deps.getTeamState()?.provisioning?.workers?.[workerId];
|
|
99
|
+
if (!record) {
|
|
100
|
+
return { ok: true, managed: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (record.role !== role) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
managed: true,
|
|
107
|
+
reason: `Provisioned worker ${workerId} expected role ${record.role}, got ${role}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!launchToken || launchToken !== record.launchToken) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
managed: true,
|
|
114
|
+
reason: `Provisioned worker ${workerId} is missing a valid launch token`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (record.status === "failed" || record.status === "terminated" || record.status === "terminating") {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
managed: true,
|
|
121
|
+
reason: `Provisioned worker ${workerId} is no longer allowed to register (${record.status})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { ok: true, managed: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onWorkerRegistered(workerId: string): void {
|
|
129
|
+
if (!this.backend) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.deps.updateTeamState((state) => {
|
|
133
|
+
const record = ensureProvisioningState(state).workers[workerId];
|
|
134
|
+
if (!record) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
record.status = "registered";
|
|
139
|
+
record.registeredAt = record.registeredAt ?? now;
|
|
140
|
+
record.updatedAt = now;
|
|
141
|
+
record.idleSince = now;
|
|
142
|
+
delete record.lastError;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onWorkerHeartbeat(workerId: string, status: WorkerStatus): void {
|
|
147
|
+
if (!this.backend) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.deps.updateTeamState((state) => {
|
|
151
|
+
const record = ensureProvisioningState(state).workers[workerId];
|
|
152
|
+
if (!record) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (record.status === "launching") {
|
|
157
|
+
record.status = "registered";
|
|
158
|
+
record.registeredAt = record.registeredAt ?? now;
|
|
159
|
+
}
|
|
160
|
+
record.updatedAt = now;
|
|
161
|
+
if (status === "idle") {
|
|
162
|
+
record.idleSince = record.idleSince ?? now;
|
|
163
|
+
} else {
|
|
164
|
+
delete record.idleSince;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async onWorkerRemoved(workerId: string, reason: string): Promise<void> {
|
|
170
|
+
if (!this.backend) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const record = this.deps.getTeamState()?.provisioning?.workers?.[workerId];
|
|
174
|
+
if (!record || record.status === "terminated" || record.status === "failed") {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.deps.logger.info(`Provisioner: terminating managed worker ${workerId} (${reason})`);
|
|
179
|
+
await this.terminateManagedWorker(workerId, reason, "terminated");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async requestReconcile(reason: string): Promise<void> {
|
|
183
|
+
if (!this.backend || this.stopped) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.reconcilePromise) {
|
|
188
|
+
this.reconcileQueued = true;
|
|
189
|
+
return this.reconcilePromise;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.reconcilePromise = this.runReconcileLoop(reason)
|
|
193
|
+
.catch((err) => {
|
|
194
|
+
this.deps.logger.warn(
|
|
195
|
+
`Provisioner: reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
196
|
+
);
|
|
197
|
+
})
|
|
198
|
+
.finally(() => {
|
|
199
|
+
this.reconcilePromise = null;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return this.reconcilePromise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async stop(): Promise<void> {
|
|
206
|
+
if (!this.backend) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.stopped = true;
|
|
211
|
+
|
|
212
|
+
const state = this.deps.getTeamState();
|
|
213
|
+
const managedWorkerIds = state?.provisioning
|
|
214
|
+
? Object.entries(state.provisioning.workers)
|
|
215
|
+
.filter(([, record]) => record.provider === this.backend?.type && record.status !== "terminated")
|
|
216
|
+
.map(([workerId]) => workerId)
|
|
217
|
+
: [];
|
|
218
|
+
|
|
219
|
+
for (const workerId of managedWorkerIds) {
|
|
220
|
+
try {
|
|
221
|
+
await this.terminateManagedWorker(workerId, "controller shutdown", "terminated");
|
|
222
|
+
} catch (err) {
|
|
223
|
+
this.deps.logger.warn(
|
|
224
|
+
`Provisioner: failed to stop worker ${workerId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await this.backend.stop?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async runReconcileLoop(initialReason: string): Promise<void> {
|
|
233
|
+
let reason = initialReason;
|
|
234
|
+
do {
|
|
235
|
+
this.reconcileQueued = false;
|
|
236
|
+
await this.reconcileOnce(reason);
|
|
237
|
+
reason = "queued reconcile";
|
|
238
|
+
} while (this.reconcileQueued && !this.stopped);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async reconcileOnce(reason: string): Promise<void> {
|
|
242
|
+
if (!this.backend) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
this.deps.updateTeamState((state) => {
|
|
248
|
+
this.refreshProvisioningState(state, now);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await this.expireStalledLaunches(now);
|
|
252
|
+
|
|
253
|
+
const state = this.deps.getTeamState();
|
|
254
|
+
if (!state) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const roles = this.getProvisionableRoles();
|
|
259
|
+
for (const role of roles) {
|
|
260
|
+
const demand = this.computeRoleDemand(state, role);
|
|
261
|
+
if (demand > 0) {
|
|
262
|
+
this.deps.logger.info(`Provisioner: role ${role} needs ${demand} additional worker(s) (${reason})`);
|
|
263
|
+
}
|
|
264
|
+
for (let i = 0; i < demand; i += 1) {
|
|
265
|
+
if (this.hasRecentProvisioningFailure(role)) {
|
|
266
|
+
this.deps.logger.warn(`Provisioner: recent ${role} launch failure detected; cooling down`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
await this.launchWorker(role);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await this.scaleDownIdleWorkers(now);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async expireStalledLaunches(now: number): Promise<void> {
|
|
277
|
+
const state = this.deps.getTeamState();
|
|
278
|
+
if (!state?.provisioning) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const timedOut = Object.values(state.provisioning.workers)
|
|
283
|
+
.filter((record) => record.status === "launching")
|
|
284
|
+
.filter((record) => now - record.requestedAt > this.deps.config.workerProvisioningStartupTimeoutMs);
|
|
285
|
+
|
|
286
|
+
for (const record of timedOut) {
|
|
287
|
+
await this.terminateManagedWorker(
|
|
288
|
+
record.workerId,
|
|
289
|
+
`startup timeout exceeded (${this.deps.config.workerProvisioningStartupTimeoutMs}ms)`,
|
|
290
|
+
"failed",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async scaleDownIdleWorkers(now: number): Promise<void> {
|
|
296
|
+
const state = this.deps.getTeamState();
|
|
297
|
+
if (!state?.provisioning) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const roles = this.getProvisionableRoles();
|
|
302
|
+
for (const role of roles) {
|
|
303
|
+
const activeWorkers = Object.values(state.workers).filter(
|
|
304
|
+
(worker) => worker.role === role && worker.status !== "offline",
|
|
305
|
+
);
|
|
306
|
+
const pendingDemand = this.countPendingTasksForRole(state, role);
|
|
307
|
+
if (pendingDemand > 0) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let remainingActive = activeWorkers.length;
|
|
312
|
+
const managedIdleWorkers = activeWorkers
|
|
313
|
+
.filter((worker) => worker.status === "idle")
|
|
314
|
+
.map((worker) => ({
|
|
315
|
+
worker,
|
|
316
|
+
record: state.provisioning?.workers[worker.id],
|
|
317
|
+
}))
|
|
318
|
+
.filter((entry): entry is { worker: typeof activeWorkers[number]; record: ProvisionedWorkerRecord } => Boolean(entry.record))
|
|
319
|
+
.filter(({ record }) => record.status === "registered")
|
|
320
|
+
.sort((a, b) => (a.record.idleSince ?? Number.MAX_SAFE_INTEGER) - (b.record.idleSince ?? Number.MAX_SAFE_INTEGER));
|
|
321
|
+
|
|
322
|
+
for (const entry of managedIdleWorkers) {
|
|
323
|
+
if (remainingActive <= this.deps.config.workerProvisioningMinPerRole) {
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
if (!entry.record.idleSince || now - entry.record.idleSince < this.deps.config.workerProvisioningIdleTtlMs) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
await this.terminateManagedWorker(entry.worker.id, "idle TTL exceeded", "terminated");
|
|
330
|
+
remainingActive -= 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private hasRecentProvisioningFailure(role: RoleId): boolean {
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
const records = this.deps.getTeamState()?.provisioning?.workers ?? {};
|
|
338
|
+
return Object.values(records).some((record) =>
|
|
339
|
+
record.role === role &&
|
|
340
|
+
record.status === "failed" &&
|
|
341
|
+
now - record.updatedAt < PROVISIONING_FAILURE_COOLDOWN_MS
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async launchWorker(role: RoleId): Promise<void> {
|
|
346
|
+
if (!this.backend) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const workerId = `provisioned-${role}-${generateId()}`;
|
|
351
|
+
const launchToken = `${generateId()}-${generateId()}`;
|
|
352
|
+
const controllerUrl = this.resolveControllerUrl();
|
|
353
|
+
const workerPort = this.backend.type === "process"
|
|
354
|
+
? await reserveEphemeralPort()
|
|
355
|
+
: DEFAULT_CONTAINER_WORKER_PORT;
|
|
356
|
+
const gatewayPort = this.backend.type === "process"
|
|
357
|
+
? await reserveEphemeralPort()
|
|
358
|
+
: DEFAULT_CONTAINER_GATEWAY_PORT;
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
|
|
361
|
+
this.deps.updateTeamState((state) => {
|
|
362
|
+
ensureProvisioningState(state).workers[workerId] = {
|
|
363
|
+
workerId,
|
|
364
|
+
role,
|
|
365
|
+
provider: this.backend!.type,
|
|
366
|
+
status: "launching",
|
|
367
|
+
launchToken,
|
|
368
|
+
requestedAt: now,
|
|
369
|
+
updatedAt: now,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const baseConfig = await this.loadBaseOpenClawConfig();
|
|
375
|
+
const workerConfig = buildProvisionedWorkerConfig(baseConfig, this.deps.config, {
|
|
376
|
+
role,
|
|
377
|
+
controllerUrl,
|
|
378
|
+
workerPort,
|
|
379
|
+
gatewayPort,
|
|
380
|
+
});
|
|
381
|
+
const launchResult = await this.backend.launch({
|
|
382
|
+
workerId,
|
|
383
|
+
role,
|
|
384
|
+
launchToken,
|
|
385
|
+
controllerUrl,
|
|
386
|
+
workerPort,
|
|
387
|
+
gatewayPort,
|
|
388
|
+
env: this.buildForwardedEnv(),
|
|
389
|
+
configJson: `${JSON.stringify(workerConfig, null, 2)}\n`,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
this.deps.updateTeamState((state) => {
|
|
393
|
+
const record = ensureProvisioningState(state).workers[workerId];
|
|
394
|
+
if (!record) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
record.instanceId = launchResult.instanceId ?? record.instanceId;
|
|
398
|
+
record.instanceName = launchResult.instanceName ?? record.instanceName;
|
|
399
|
+
record.runtimeHomeDir = launchResult.runtimeHomeDir ?? record.runtimeHomeDir;
|
|
400
|
+
record.updatedAt = Date.now();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
this.deps.logger.info(`Provisioner: launched ${role} worker ${workerId} via ${this.backend.type}`);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
406
|
+
this.deps.updateTeamState((state) => {
|
|
407
|
+
const record = ensureProvisioningState(state).workers[workerId];
|
|
408
|
+
if (!record) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
record.status = "failed";
|
|
412
|
+
record.updatedAt = Date.now();
|
|
413
|
+
record.lastError = message;
|
|
414
|
+
});
|
|
415
|
+
this.deps.logger.warn(`Provisioner: failed to launch ${role} worker ${workerId}: ${message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async terminateManagedWorker(
|
|
420
|
+
workerId: string,
|
|
421
|
+
reason: string,
|
|
422
|
+
terminalStatus: Extract<ProvisionedWorkerStatus, "failed" | "terminated">,
|
|
423
|
+
): Promise<void> {
|
|
424
|
+
if (!this.backend) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const state = this.deps.getTeamState();
|
|
429
|
+
const record = state?.provisioning?.workers?.[workerId];
|
|
430
|
+
if (!record) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.deps.updateTeamState((draft) => {
|
|
435
|
+
const current = ensureProvisioningState(draft).workers[workerId];
|
|
436
|
+
if (!current) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
current.status = "terminating";
|
|
440
|
+
current.updatedAt = Date.now();
|
|
441
|
+
if (draft.workers[workerId]) {
|
|
442
|
+
draft.workers[workerId].status = "offline";
|
|
443
|
+
draft.workers[workerId].currentTaskId = undefined;
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await this.backend.terminate(record);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
this.deps.logger.warn(
|
|
451
|
+
`Provisioner: backend terminate failed for ${workerId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.deps.updateTeamState((draft) => {
|
|
456
|
+
const current = ensureProvisioningState(draft).workers[workerId];
|
|
457
|
+
if (!current) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
current.status = terminalStatus;
|
|
461
|
+
current.updatedAt = Date.now();
|
|
462
|
+
delete current.idleSince;
|
|
463
|
+
if (terminalStatus === "failed") {
|
|
464
|
+
current.lastError = reason;
|
|
465
|
+
} else {
|
|
466
|
+
delete current.lastError;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private getProvisionableRoles(): RoleId[] {
|
|
472
|
+
return this.deps.config.workerProvisioningRoles.length > 0
|
|
473
|
+
? this.deps.config.workerProvisioningRoles
|
|
474
|
+
: ROLES.map((role) => role.id);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private countPendingTasksForRole(state: TeamState, role: RoleId): number {
|
|
478
|
+
return Object.values(state.tasks).filter((task) => this.doesTaskNeedRole(task, state, role)).length;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private doesTaskNeedRole(task: TaskInfo, state: TeamState, role: RoleId): boolean {
|
|
482
|
+
if (task.status !== "pending" && task.status !== "assigned") {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
if (task.assignedWorkerId) {
|
|
486
|
+
const assignedWorker = state.workers[task.assignedWorkerId];
|
|
487
|
+
if (assignedWorker && assignedWorker.status !== "offline") {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return this.inferTaskRole(task) === role;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private inferTaskRole(task: TaskInfo): RoleId | null {
|
|
495
|
+
if (task.assignedRole) {
|
|
496
|
+
return task.assignedRole;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const text = `${task.title} ${task.description}`.toLowerCase();
|
|
500
|
+
let bestRole: RoleId | null = null;
|
|
501
|
+
let bestScore = 0;
|
|
502
|
+
|
|
503
|
+
for (const role of ROLES) {
|
|
504
|
+
const roleTokens = [
|
|
505
|
+
role.id,
|
|
506
|
+
role.label,
|
|
507
|
+
...role.capabilities,
|
|
508
|
+
].flatMap((entry) => entry.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 2));
|
|
509
|
+
const uniqueTokens = [...new Set(roleTokens)];
|
|
510
|
+
const score = uniqueTokens.reduce((count, token) => count + (text.includes(token) ? 1 : 0), 0);
|
|
511
|
+
if (score > bestScore) {
|
|
512
|
+
bestScore = score;
|
|
513
|
+
bestRole = role.id;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return bestScore > 0 ? bestRole : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private computeRoleDemand(state: TeamState, role: RoleId): number {
|
|
521
|
+
const pendingDemand = this.countPendingTasksForRole(state, role);
|
|
522
|
+
const activeWorkers = Object.values(state.workers).filter(
|
|
523
|
+
(worker) => worker.role === role && worker.status !== "offline",
|
|
524
|
+
);
|
|
525
|
+
const idleWorkers = activeWorkers.filter((worker) => worker.status === "idle").length;
|
|
526
|
+
const launchingWorkers = Object.values(state.provisioning?.workers ?? {}).filter(
|
|
527
|
+
(record) => record.role === role && record.status === "launching",
|
|
528
|
+
).length;
|
|
529
|
+
|
|
530
|
+
const warmShortfall = Math.max(
|
|
531
|
+
0,
|
|
532
|
+
this.deps.config.workerProvisioningMinPerRole - (activeWorkers.length + launchingWorkers),
|
|
533
|
+
);
|
|
534
|
+
const queueDrivenNeed = Math.max(0, pendingDemand - idleWorkers - launchingWorkers);
|
|
535
|
+
const cap = Math.max(
|
|
536
|
+
0,
|
|
537
|
+
this.deps.config.workerProvisioningMaxPerRole - activeWorkers.length - launchingWorkers,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
return Math.min(cap, Math.max(warmShortfall, queueDrivenNeed));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private resolveControllerUrl(): string {
|
|
544
|
+
if (this.deps.config.workerProvisioningControllerUrl) {
|
|
545
|
+
return this.deps.config.workerProvisioningControllerUrl;
|
|
546
|
+
}
|
|
547
|
+
if (this.backend?.type === "process") {
|
|
548
|
+
return `http://127.0.0.1:${this.deps.config.port}`;
|
|
549
|
+
}
|
|
550
|
+
throw new Error(
|
|
551
|
+
`workerProvisioningControllerUrl is required when workerProvisioningType=${this.backend?.type}`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private buildForwardedEnv(): Record<string, string> {
|
|
556
|
+
const env: Record<string, string> = {
|
|
557
|
+
...this.deps.config.workerProvisioningExtraEnv,
|
|
558
|
+
};
|
|
559
|
+
for (const name of this.deps.config.workerProvisioningPassEnv) {
|
|
560
|
+
const value = process.env[name];
|
|
561
|
+
if (typeof value === "string" && value.length > 0) {
|
|
562
|
+
env[name] = value;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return env;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private async loadBaseOpenClawConfig(): Promise<Record<string, unknown>> {
|
|
569
|
+
if (!this.baseConfigPromise) {
|
|
570
|
+
this.baseConfigPromise = loadOpenClawConfig(resolveDefaultOpenClawConfigPath());
|
|
571
|
+
}
|
|
572
|
+
return cloneJson(await this.baseConfigPromise);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private refreshProvisioningState(state: TeamState, now: number): boolean {
|
|
576
|
+
let changed = !state.provisioning || typeof state.provisioning !== "object";
|
|
577
|
+
const provisioning = ensureProvisioningState(state);
|
|
578
|
+
|
|
579
|
+
for (const [workerId, record] of Object.entries(provisioning.workers)) {
|
|
580
|
+
const worker = state.workers[workerId];
|
|
581
|
+
if (worker && worker.status !== "offline") {
|
|
582
|
+
if (record.status === "launching") {
|
|
583
|
+
record.status = "registered";
|
|
584
|
+
record.registeredAt = record.registeredAt ?? worker.registeredAt ?? now;
|
|
585
|
+
changed = true;
|
|
586
|
+
}
|
|
587
|
+
if (record.status === "registered") {
|
|
588
|
+
if (worker.status === "idle") {
|
|
589
|
+
if (!record.idleSince) {
|
|
590
|
+
record.idleSince = now;
|
|
591
|
+
changed = true;
|
|
592
|
+
}
|
|
593
|
+
} else if (record.idleSince) {
|
|
594
|
+
delete record.idleSince;
|
|
595
|
+
changed = true;
|
|
596
|
+
}
|
|
597
|
+
if (record.updatedAt < worker.lastHeartbeat) {
|
|
598
|
+
record.updatedAt = worker.lastHeartbeat;
|
|
599
|
+
changed = true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if ((record.status === "failed" || record.status === "terminated") &&
|
|
605
|
+
now - record.updatedAt > PROVISIONING_RECORD_RETENTION_MS) {
|
|
606
|
+
delete provisioning.workers[workerId];
|
|
607
|
+
changed = true;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return changed;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
class ProcessProvisioner implements WorkerProvisionerBackend {
|
|
616
|
+
readonly type = "process" as const;
|
|
617
|
+
private readonly logger: PluginLogger;
|
|
618
|
+
private readonly processByWorkerId = new Map<string, ChildProcess>();
|
|
619
|
+
private readonly baseDirPromise: Promise<string>;
|
|
620
|
+
|
|
621
|
+
constructor(logger: PluginLogger) {
|
|
622
|
+
this.logger = logger;
|
|
623
|
+
this.baseDirPromise = fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-provisioned-"));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async launch(spec: LaunchSpec): Promise<LaunchResult> {
|
|
627
|
+
const baseDir = await this.baseDirPromise;
|
|
628
|
+
const runtimeHomeDir = await fs.mkdtemp(path.join(baseDir, `${sanitizePathSegment(spec.role)}-`));
|
|
629
|
+
const stateDir = path.join(runtimeHomeDir, ".openclaw");
|
|
630
|
+
const configPath = path.join(stateDir, "openclaw.json");
|
|
631
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
632
|
+
await fs.writeFile(configPath, spec.configJson, "utf8");
|
|
633
|
+
|
|
634
|
+
const gatewayEntrypoint = resolveGatewayEntrypoint();
|
|
635
|
+
const child = spawn(process.execPath, [
|
|
636
|
+
gatewayEntrypoint,
|
|
637
|
+
"gateway",
|
|
638
|
+
"--allow-unconfigured",
|
|
639
|
+
"--bind",
|
|
640
|
+
"loopback",
|
|
641
|
+
"--port",
|
|
642
|
+
String(spec.gatewayPort),
|
|
643
|
+
], {
|
|
644
|
+
cwd: path.dirname(gatewayEntrypoint),
|
|
645
|
+
env: {
|
|
646
|
+
...process.env,
|
|
647
|
+
...spec.env,
|
|
648
|
+
HOME: runtimeHomeDir,
|
|
649
|
+
OPENCLAW_HOME: runtimeHomeDir,
|
|
650
|
+
OPENCLAW_STATE_DIR: stateDir,
|
|
651
|
+
OPENCLAW_CONFIG_PATH: configPath,
|
|
652
|
+
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
653
|
+
TEAMCLAW_WORKER_ID: spec.workerId,
|
|
654
|
+
TEAMCLAW_LAUNCH_TOKEN: spec.launchToken,
|
|
655
|
+
},
|
|
656
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
this.processByWorkerId.set(spec.workerId, child);
|
|
660
|
+
attachChildLogs(child, this.logger, `ProvisionedWorker[${spec.role}]`);
|
|
661
|
+
child.on("exit", (code: number | null, signal: string | null) => {
|
|
662
|
+
this.processByWorkerId.delete(spec.workerId);
|
|
663
|
+
this.logger.info(
|
|
664
|
+
`Provisioner: process worker ${spec.workerId} exited (code=${String(code)}, signal=${String(signal)})`,
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
instanceId: child.pid ? `pid:${child.pid}` : undefined,
|
|
670
|
+
instanceName: spec.workerId,
|
|
671
|
+
runtimeHomeDir,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async terminate(record: ProvisionedWorkerRecord): Promise<void> {
|
|
676
|
+
const child = this.processByWorkerId.get(record.workerId);
|
|
677
|
+
if (child) {
|
|
678
|
+
await stopChildProcess(child);
|
|
679
|
+
this.processByWorkerId.delete(record.workerId);
|
|
680
|
+
} else if (record.instanceId?.startsWith("pid:")) {
|
|
681
|
+
const pid = Number(record.instanceId.slice("pid:".length));
|
|
682
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
683
|
+
try {
|
|
684
|
+
process.kill(pid, "SIGTERM");
|
|
685
|
+
} catch {
|
|
686
|
+
// ignore
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (record.runtimeHomeDir) {
|
|
692
|
+
await fs.rm(record.runtimeHomeDir, { recursive: true, force: true }).catch(() => {
|
|
693
|
+
// ignore
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async stop(): Promise<void> {
|
|
699
|
+
for (const child of this.processByWorkerId.values()) {
|
|
700
|
+
await stopChildProcess(child).catch(() => {
|
|
701
|
+
// ignore
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
this.processByWorkerId.clear();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
class DockerProvisioner implements WorkerProvisionerBackend {
|
|
709
|
+
readonly type = "docker" as const;
|
|
710
|
+
private readonly config: PluginConfig;
|
|
711
|
+
private readonly logger: PluginLogger;
|
|
712
|
+
private readonly client: DockerApiClient;
|
|
713
|
+
|
|
714
|
+
constructor(config: PluginConfig, logger: PluginLogger) {
|
|
715
|
+
this.config = config;
|
|
716
|
+
this.logger = logger;
|
|
717
|
+
this.client = new DockerApiClient();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async launch(spec: LaunchSpec): Promise<LaunchResult> {
|
|
721
|
+
if (!this.config.workerProvisioningImage) {
|
|
722
|
+
throw new Error("workerProvisioningImage is required for docker provisioning");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
|
|
726
|
+
const env = {
|
|
727
|
+
...spec.env,
|
|
728
|
+
HOME: "/home/node",
|
|
729
|
+
OPENCLAW_HOME: "/home/node",
|
|
730
|
+
OPENCLAW_STATE_DIR: "/home/node/.openclaw",
|
|
731
|
+
OPENCLAW_CONFIG_PATH: "/home/node/.openclaw/openclaw.json",
|
|
732
|
+
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
733
|
+
TEAMCLAW_BOOTSTRAP_CONFIG_B64: Buffer.from(spec.configJson, "utf8").toString("base64"),
|
|
734
|
+
TEAMCLAW_WORKER_ID: spec.workerId,
|
|
735
|
+
TEAMCLAW_LAUNCH_TOKEN: spec.launchToken,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const script = buildContainerBootstrapScript();
|
|
739
|
+
const response = await this.client.requestJson<{ Id?: string }>(
|
|
740
|
+
"POST",
|
|
741
|
+
`/containers/create?name=${encodeURIComponent(instanceName)}`,
|
|
742
|
+
{
|
|
743
|
+
Image: this.config.workerProvisioningImage,
|
|
744
|
+
Cmd: ["sh", "-lc", script],
|
|
745
|
+
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
|
746
|
+
Labels: {
|
|
747
|
+
"teamclaw.managed": "true",
|
|
748
|
+
"teamclaw.team": this.config.teamName,
|
|
749
|
+
"teamclaw.role": spec.role,
|
|
750
|
+
"teamclaw.worker_id": spec.workerId,
|
|
751
|
+
},
|
|
752
|
+
HostConfig: {
|
|
753
|
+
Binds: this.config.workerProvisioningDockerMounts,
|
|
754
|
+
NetworkMode: this.config.workerProvisioningDockerNetwork || undefined,
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
[201],
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const instanceId = typeof response.Id === "string" ? response.Id : undefined;
|
|
761
|
+
if (!instanceId) {
|
|
762
|
+
throw new Error("Docker create did not return a container ID");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
await this.client.requestVoid("POST", `/containers/${instanceId}/start`, undefined, [204]);
|
|
766
|
+
this.logger.info(`Provisioner: started docker worker container ${instanceName} (${instanceId})`);
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
instanceId,
|
|
770
|
+
instanceName,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async terminate(record: ProvisionedWorkerRecord): Promise<void> {
|
|
775
|
+
const target = record.instanceId || record.instanceName;
|
|
776
|
+
if (!target) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
await this.client.requestVoid("DELETE", `/containers/${target}?force=1`, undefined, [204, 404]);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
class KubernetesProvisioner implements WorkerProvisionerBackend {
|
|
784
|
+
readonly type = "kubernetes" as const;
|
|
785
|
+
private readonly config: PluginConfig;
|
|
786
|
+
private readonly logger: PluginLogger;
|
|
787
|
+
|
|
788
|
+
constructor(config: PluginConfig, logger: PluginLogger) {
|
|
789
|
+
this.config = config;
|
|
790
|
+
this.logger = logger;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async launch(spec: LaunchSpec): Promise<LaunchResult> {
|
|
794
|
+
if (!this.config.workerProvisioningImage) {
|
|
795
|
+
throw new Error("workerProvisioningImage is required for kubernetes provisioning");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
|
|
799
|
+
const env = {
|
|
800
|
+
...spec.env,
|
|
801
|
+
HOME: "/home/node",
|
|
802
|
+
OPENCLAW_HOME: "/home/node",
|
|
803
|
+
OPENCLAW_STATE_DIR: "/home/node/.openclaw",
|
|
804
|
+
OPENCLAW_CONFIG_PATH: "/home/node/.openclaw/openclaw.json",
|
|
805
|
+
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
806
|
+
TEAMCLAW_BOOTSTRAP_CONFIG_B64: Buffer.from(spec.configJson, "utf8").toString("base64"),
|
|
807
|
+
TEAMCLAW_WORKER_ID: spec.workerId,
|
|
808
|
+
TEAMCLAW_LAUNCH_TOKEN: spec.launchToken,
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const manifest = {
|
|
812
|
+
apiVersion: "v1",
|
|
813
|
+
kind: "Pod",
|
|
814
|
+
metadata: {
|
|
815
|
+
name: instanceName,
|
|
816
|
+
namespace: this.config.workerProvisioningKubernetesNamespace,
|
|
817
|
+
labels: {
|
|
818
|
+
app: "teamclaw-worker",
|
|
819
|
+
"teamclaw.managed": "true",
|
|
820
|
+
"teamclaw.team": sanitizeName(this.config.teamName, 40),
|
|
821
|
+
"teamclaw.role": sanitizeName(spec.role, 40),
|
|
822
|
+
...this.config.workerProvisioningKubernetesLabels,
|
|
823
|
+
},
|
|
824
|
+
annotations: {
|
|
825
|
+
...this.config.workerProvisioningKubernetesAnnotations,
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
spec: {
|
|
829
|
+
restartPolicy: "Never",
|
|
830
|
+
hostname: buildManagedHostname(this.config.teamName, spec.role, spec.workerId),
|
|
831
|
+
serviceAccountName: this.config.workerProvisioningKubernetesServiceAccount || undefined,
|
|
832
|
+
containers: [
|
|
833
|
+
{
|
|
834
|
+
name: "worker",
|
|
835
|
+
image: this.config.workerProvisioningImage,
|
|
836
|
+
command: ["sh", "-lc"],
|
|
837
|
+
args: [buildContainerBootstrapScript()],
|
|
838
|
+
env: Object.entries(env).map(([name, value]) => ({ name, value })),
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
await runCommand(
|
|
845
|
+
"kubectl",
|
|
846
|
+
[
|
|
847
|
+
...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
|
|
848
|
+
"apply",
|
|
849
|
+
"-f",
|
|
850
|
+
"-",
|
|
851
|
+
],
|
|
852
|
+
JSON.stringify(manifest),
|
|
853
|
+
);
|
|
854
|
+
this.logger.info(`Provisioner: applied kubernetes pod ${instanceName}`);
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
instanceId: instanceName,
|
|
858
|
+
instanceName,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async terminate(record: ProvisionedWorkerRecord): Promise<void> {
|
|
863
|
+
const podName = record.instanceName || record.instanceId;
|
|
864
|
+
if (!podName) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
await runCommand("kubectl", [
|
|
869
|
+
...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
|
|
870
|
+
"-n",
|
|
871
|
+
this.config.workerProvisioningKubernetesNamespace,
|
|
872
|
+
"delete",
|
|
873
|
+
"pod",
|
|
874
|
+
podName,
|
|
875
|
+
"--ignore-not-found=true",
|
|
876
|
+
"--grace-period=0",
|
|
877
|
+
"--force",
|
|
878
|
+
]);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
class DockerApiClient {
|
|
883
|
+
private readonly endpoint: DockerEndpoint;
|
|
884
|
+
|
|
885
|
+
constructor() {
|
|
886
|
+
this.endpoint = resolveDockerEndpoint();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async requestJson<T>(method: string, requestPath: string, body?: unknown, okStatuses: number[] = [200]): Promise<T> {
|
|
890
|
+
const response = await this.request(method, requestPath, body, okStatuses);
|
|
891
|
+
if (!response.body) {
|
|
892
|
+
return {} as T;
|
|
893
|
+
}
|
|
894
|
+
return JSON.parse(response.body) as T;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async requestVoid(method: string, requestPath: string, body?: unknown, okStatuses: number[] = [200]): Promise<void> {
|
|
898
|
+
await this.request(method, requestPath, body, okStatuses);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private async request(
|
|
902
|
+
method: string,
|
|
903
|
+
requestPath: string,
|
|
904
|
+
body: unknown,
|
|
905
|
+
okStatuses: number[],
|
|
906
|
+
): Promise<{ status: number; body: string }> {
|
|
907
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
908
|
+
const finalPath = `/${DOCKER_API_VERSION}${requestPath}`;
|
|
909
|
+
|
|
910
|
+
return await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
|
911
|
+
const transport = this.endpoint.protocol === "https:" ? https : http;
|
|
912
|
+
const req = transport.request({
|
|
913
|
+
method,
|
|
914
|
+
socketPath: this.endpoint.socketPath,
|
|
915
|
+
hostname: this.endpoint.hostname,
|
|
916
|
+
port: this.endpoint.port,
|
|
917
|
+
path: finalPath,
|
|
918
|
+
headers: payload
|
|
919
|
+
? {
|
|
920
|
+
"Content-Type": "application/json",
|
|
921
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
922
|
+
}
|
|
923
|
+
: undefined,
|
|
924
|
+
}, (res: any) => {
|
|
925
|
+
const chunks: Buffer[] = [];
|
|
926
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
927
|
+
res.on("end", () => {
|
|
928
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
929
|
+
const status = res.statusCode ?? 500;
|
|
930
|
+
if (!okStatuses.includes(status)) {
|
|
931
|
+
const message = extractDockerErrorMessage(text) || `Docker API ${method} ${requestPath} failed with ${status}`;
|
|
932
|
+
reject(new Error(message));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
resolve({ status, body: text });
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
req.on("error", reject);
|
|
939
|
+
if (payload) {
|
|
940
|
+
req.write(payload);
|
|
941
|
+
}
|
|
942
|
+
req.end();
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
type DockerEndpoint = {
|
|
948
|
+
protocol: "http:" | "https:";
|
|
949
|
+
socketPath?: string;
|
|
950
|
+
hostname?: string;
|
|
951
|
+
port?: number;
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
function createProvisionerBackend(
|
|
955
|
+
config: PluginConfig,
|
|
956
|
+
logger: PluginLogger,
|
|
957
|
+
): WorkerProvisionerBackend | null {
|
|
958
|
+
switch (config.workerProvisioningType) {
|
|
959
|
+
case "process":
|
|
960
|
+
return new ProcessProvisioner(logger);
|
|
961
|
+
case "docker":
|
|
962
|
+
return new DockerProvisioner(config, logger);
|
|
963
|
+
case "kubernetes":
|
|
964
|
+
return new KubernetesProvisioner(config, logger);
|
|
965
|
+
case "none":
|
|
966
|
+
default:
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function buildProvisionedWorkerConfig(
|
|
972
|
+
baseConfig: Record<string, unknown>,
|
|
973
|
+
controllerConfig: PluginConfig,
|
|
974
|
+
spec: {
|
|
975
|
+
role: RoleId;
|
|
976
|
+
controllerUrl: string;
|
|
977
|
+
workerPort: number;
|
|
978
|
+
gatewayPort: number;
|
|
979
|
+
},
|
|
980
|
+
): Record<string, unknown> {
|
|
981
|
+
const config = cloneJson(baseConfig);
|
|
982
|
+
const gateway = ensureRecord(config.gateway);
|
|
983
|
+
gateway.mode = "local";
|
|
984
|
+
gateway.bind = "loopback";
|
|
985
|
+
gateway.port = spec.gatewayPort;
|
|
986
|
+
config.gateway = gateway;
|
|
987
|
+
|
|
988
|
+
const plugins = ensureRecord(config.plugins);
|
|
989
|
+
plugins.enabled = true;
|
|
990
|
+
const entries = ensureRecord(plugins.entries);
|
|
991
|
+
const teamclawEntry = ensureRecord(entries.teamclaw);
|
|
992
|
+
teamclawEntry.enabled = true;
|
|
993
|
+
const teamclawConfig = ensureRecord(teamclawEntry.config);
|
|
994
|
+
teamclawConfig.mode = "worker";
|
|
995
|
+
teamclawConfig.role = spec.role;
|
|
996
|
+
teamclawConfig.port = spec.workerPort;
|
|
997
|
+
teamclawConfig.controllerUrl = spec.controllerUrl;
|
|
998
|
+
teamclawConfig.teamName = controllerConfig.teamName;
|
|
999
|
+
teamclawConfig.heartbeatIntervalMs = controllerConfig.heartbeatIntervalMs;
|
|
1000
|
+
teamclawConfig.taskTimeoutMs = controllerConfig.taskTimeoutMs;
|
|
1001
|
+
teamclawConfig.gitEnabled = controllerConfig.gitEnabled;
|
|
1002
|
+
teamclawConfig.gitRemoteUrl = controllerConfig.gitRemoteUrl;
|
|
1003
|
+
teamclawConfig.gitDefaultBranch = controllerConfig.gitDefaultBranch;
|
|
1004
|
+
teamclawConfig.gitAuthorName = controllerConfig.gitAuthorName;
|
|
1005
|
+
teamclawConfig.gitAuthorEmail = controllerConfig.gitAuthorEmail;
|
|
1006
|
+
teamclawConfig.localRoles = [];
|
|
1007
|
+
teamclawConfig.workerProvisioningType = "none";
|
|
1008
|
+
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
1009
|
+
teamclawConfig.workerProvisioningRoles = [];
|
|
1010
|
+
teamclawConfig.workerProvisioningMinPerRole = 0;
|
|
1011
|
+
teamclawConfig.workerProvisioningMaxPerRole = 1;
|
|
1012
|
+
teamclawConfig.workerProvisioningIdleTtlMs = controllerConfig.workerProvisioningIdleTtlMs;
|
|
1013
|
+
teamclawConfig.workerProvisioningStartupTimeoutMs = controllerConfig.workerProvisioningStartupTimeoutMs;
|
|
1014
|
+
teamclawConfig.workerProvisioningImage = "";
|
|
1015
|
+
teamclawConfig.workerProvisioningPassEnv = [];
|
|
1016
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
1017
|
+
teamclawConfig.workerProvisioningDockerNetwork = "";
|
|
1018
|
+
teamclawConfig.workerProvisioningDockerMounts = [];
|
|
1019
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
1020
|
+
teamclawConfig.workerProvisioningKubernetesContext = "";
|
|
1021
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
1022
|
+
teamclawConfig.workerProvisioningKubernetesLabels = {};
|
|
1023
|
+
teamclawConfig.workerProvisioningKubernetesAnnotations = {};
|
|
1024
|
+
teamclawEntry.config = teamclawConfig;
|
|
1025
|
+
entries.teamclaw = teamclawEntry;
|
|
1026
|
+
plugins.entries = entries;
|
|
1027
|
+
config.plugins = plugins;
|
|
1028
|
+
|
|
1029
|
+
return config;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function ensureProvisioningState(state: TeamState): TeamProvisioningState {
|
|
1033
|
+
if (!state.provisioning || typeof state.provisioning !== "object") {
|
|
1034
|
+
state.provisioning = { workers: {} };
|
|
1035
|
+
}
|
|
1036
|
+
if (!state.provisioning.workers || typeof state.provisioning.workers !== "object") {
|
|
1037
|
+
state.provisioning.workers = {};
|
|
1038
|
+
}
|
|
1039
|
+
return state.provisioning;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function loadOpenClawConfig(configPath: string): Promise<Record<string, unknown>> {
|
|
1043
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
1044
|
+
return parseLooseJsonObject(raw, configPath);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function cloneJson<T>(value: T): T {
|
|
1048
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function ensureRecord(value: unknown): Record<string, unknown> {
|
|
1052
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1053
|
+
? { ...(value as Record<string, unknown>) }
|
|
1054
|
+
: {};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function parseLooseJsonObject(raw: string, configPath: string): Record<string, unknown> {
|
|
1058
|
+
try {
|
|
1059
|
+
return JSON5.parse(raw) as Record<string, unknown>;
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
`Failed to parse OpenClaw config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
async function reserveEphemeralPort(): Promise<number> {
|
|
1068
|
+
return await new Promise<number>((resolve, reject) => {
|
|
1069
|
+
const server = net.createServer();
|
|
1070
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1071
|
+
const address = server.address();
|
|
1072
|
+
const port = address && typeof address === "object" ? address.port : 0;
|
|
1073
|
+
server.close((err: Error | undefined | null) => {
|
|
1074
|
+
if (err) {
|
|
1075
|
+
reject(err);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (!port) {
|
|
1079
|
+
reject(new Error("Failed to reserve ephemeral port"));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
resolve(port);
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
server.on("error", reject);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function resolveGatewayEntrypoint(): string {
|
|
1090
|
+
const scriptPath = process.argv[1];
|
|
1091
|
+
if (!scriptPath) {
|
|
1092
|
+
throw new Error("Unable to resolve OpenClaw gateway entrypoint");
|
|
1093
|
+
}
|
|
1094
|
+
return path.resolve(scriptPath);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function attachChildLogs(child: ChildProcess, logger: PluginLogger, prefix: string): void {
|
|
1098
|
+
if (child.stdout) {
|
|
1099
|
+
const stdoutReader = readline.createInterface({ input: child.stdout });
|
|
1100
|
+
stdoutReader.on("line", (line: string) => {
|
|
1101
|
+
logger.info(`${prefix}: ${line}`);
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
if (child.stderr) {
|
|
1105
|
+
const stderrReader = readline.createInterface({ input: child.stderr });
|
|
1106
|
+
stderrReader.on("line", (line: string) => {
|
|
1107
|
+
logger.warn(`${prefix}: ${line}`);
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function stopChildProcess(child: ChildProcess): Promise<void> {
|
|
1113
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
await new Promise<void>((resolve) => {
|
|
1118
|
+
const timeout = setTimeout(() => {
|
|
1119
|
+
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
|
1120
|
+
try {
|
|
1121
|
+
process.kill(child.pid, "SIGKILL");
|
|
1122
|
+
} catch {
|
|
1123
|
+
// ignore
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
resolve();
|
|
1127
|
+
}, PROCESS_TERMINATION_TIMEOUT_MS);
|
|
1128
|
+
const timer = timeout as unknown as { unref?: () => void };
|
|
1129
|
+
timer.unref?.();
|
|
1130
|
+
|
|
1131
|
+
child.once("exit", () => {
|
|
1132
|
+
clearTimeout(timeout);
|
|
1133
|
+
resolve();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
child.kill("SIGTERM");
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function sanitizePathSegment(value: string): string {
|
|
1141
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1142
|
+
return normalized || "worker";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function sanitizeName(value: string, maxLength = 63): string {
|
|
1146
|
+
const normalized = sanitizePathSegment(value).slice(0, maxLength).replace(/^-+|-+$/g, "");
|
|
1147
|
+
return normalized || "teamclaw";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function buildManagedInstanceName(teamName: string, role: RoleId, workerId: string): string {
|
|
1151
|
+
return buildManagedName("teamclaw", teamName, role, workerId, {
|
|
1152
|
+
teamBudget: 18,
|
|
1153
|
+
roleBudget: 12,
|
|
1154
|
+
workerBudget: 12,
|
|
1155
|
+
hashLength: 8,
|
|
1156
|
+
maxLength: 63,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function buildManagedHostname(teamName: string, role: RoleId, workerId: string): string {
|
|
1161
|
+
return buildManagedName("tc", teamName, role, workerId, {
|
|
1162
|
+
teamBudget: 10,
|
|
1163
|
+
roleBudget: 8,
|
|
1164
|
+
workerBudget: 6,
|
|
1165
|
+
hashLength: 6,
|
|
1166
|
+
maxLength: 40,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function buildManagedName(
|
|
1171
|
+
prefix: string,
|
|
1172
|
+
teamName: string,
|
|
1173
|
+
role: RoleId,
|
|
1174
|
+
workerId: string,
|
|
1175
|
+
options: {
|
|
1176
|
+
teamBudget: number;
|
|
1177
|
+
roleBudget: number;
|
|
1178
|
+
workerBudget: number;
|
|
1179
|
+
hashLength: number;
|
|
1180
|
+
maxLength: number;
|
|
1181
|
+
},
|
|
1182
|
+
): string {
|
|
1183
|
+
const teamPart = sanitizeLeadingSegment(teamName, options.teamBudget, "team");
|
|
1184
|
+
const rolePart = sanitizeLeadingSegment(role, options.roleBudget, "worker");
|
|
1185
|
+
const workerPart = sanitizeTrailingSegment(workerId, options.workerBudget, "worker");
|
|
1186
|
+
const hash = shortStableHash(`${teamName}:${role}:${workerId}`).slice(0, options.hashLength);
|
|
1187
|
+
return sanitizeName(`${prefix}-${teamPart}-${rolePart}-${workerPart}-${hash}`, options.maxLength);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function shortStableHash(value: string): string {
|
|
1191
|
+
return createHash("sha1").update(value).digest("hex");
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function sanitizeLeadingSegment(value: string, maxLength: number, fallback: string): string {
|
|
1195
|
+
const normalized = sanitizePathSegment(value).slice(0, maxLength).replace(/^-+|-+$/g, "");
|
|
1196
|
+
return normalized || fallback;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function sanitizeTrailingSegment(value: string, maxLength: number, fallback: string): string {
|
|
1200
|
+
const normalized = sanitizePathSegment(value).slice(-maxLength).replace(/^-+|-+$/g, "");
|
|
1201
|
+
return normalized || fallback;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function buildContainerBootstrapScript(): string {
|
|
1205
|
+
return [
|
|
1206
|
+
"set -eu",
|
|
1207
|
+
"mkdir -p \"$OPENCLAW_STATE_DIR\"",
|
|
1208
|
+
"node -e 'const fs=require(\"fs\"); const configPath=process.env.OPENCLAW_CONFIG_PATH; const raw=Buffer.from(process.env.TEAMCLAW_BOOTSTRAP_CONFIG_B64||\"\", \"base64\").toString(\"utf8\"); fs.mkdirSync(require(\"path\").dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, raw);'",
|
|
1209
|
+
"exec node dist/index.js gateway --allow-unconfigured",
|
|
1210
|
+
].join("\n");
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function resolveDockerEndpoint(): DockerEndpoint {
|
|
1214
|
+
const dockerHost = process.env.DOCKER_HOST?.trim();
|
|
1215
|
+
if (!dockerHost) {
|
|
1216
|
+
return {
|
|
1217
|
+
protocol: "http:",
|
|
1218
|
+
socketPath: "/var/run/docker.sock",
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (dockerHost.startsWith("unix://")) {
|
|
1223
|
+
return {
|
|
1224
|
+
protocol: "http:",
|
|
1225
|
+
socketPath: dockerHost.slice("unix://".length),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const normalized = dockerHost.startsWith("tcp://")
|
|
1230
|
+
? dockerHost.replace(/^tcp:\/\//, "http://")
|
|
1231
|
+
: dockerHost;
|
|
1232
|
+
const url = new URL(normalized);
|
|
1233
|
+
return {
|
|
1234
|
+
protocol: url.protocol === "https:" ? "https:" : "http:",
|
|
1235
|
+
hostname: url.hostname,
|
|
1236
|
+
port: url.port ? Number(url.port) : (url.protocol === "https:" ? 443 : 2375),
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function extractDockerErrorMessage(body: string): string | null {
|
|
1241
|
+
if (!body.trim()) {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
try {
|
|
1245
|
+
const parsed = JSON.parse(body) as { message?: unknown };
|
|
1246
|
+
return typeof parsed.message === "string" ? parsed.message : body;
|
|
1247
|
+
} catch {
|
|
1248
|
+
return body;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async function runCommand(command: string, args: string[], stdin?: string): Promise<void> {
|
|
1253
|
+
await new Promise<void>((resolve, reject) => {
|
|
1254
|
+
const child = spawn(command, args, {
|
|
1255
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1256
|
+
env: process.env,
|
|
1257
|
+
});
|
|
1258
|
+
let stderr = "";
|
|
1259
|
+
let stdout = "";
|
|
1260
|
+
|
|
1261
|
+
if (stdin) {
|
|
1262
|
+
child.stdin?.end(stdin);
|
|
1263
|
+
} else {
|
|
1264
|
+
child.stdin?.end();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
child.stdout?.on("data", (chunk: Uint8Array | string) => {
|
|
1268
|
+
stdout += chunk.toString("utf8");
|
|
1269
|
+
});
|
|
1270
|
+
child.stderr?.on("data", (chunk: Uint8Array | string) => {
|
|
1271
|
+
stderr += chunk.toString("utf8");
|
|
1272
|
+
});
|
|
1273
|
+
child.on("error", reject);
|
|
1274
|
+
child.on("exit", (code: number | null) => {
|
|
1275
|
+
if (code === 0) {
|
|
1276
|
+
resolve();
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
reject(new Error(`${command} ${args.join(" ")} failed (${code}): ${(stderr || stdout).trim()}`));
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function buildKubectlContextArgs(context: string): string[] {
|
|
1285
|
+
return context ? ["--context", context] : [];
|
|
1286
|
+
}
|