@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
package/src/discovery.ts
CHANGED
|
@@ -66,7 +66,7 @@ export class MDnsBrowser {
|
|
|
66
66
|
|
|
67
67
|
browser.on("up", (service) => {
|
|
68
68
|
const txtRecord = service.txt as Record<string, string> | undefined;
|
|
69
|
-
const svcTeamName = txtRecord?.teamName ?? "
|
|
69
|
+
const svcTeamName = txtRecord?.teamName ?? "TeamClaw";
|
|
70
70
|
const host = Array.isArray(service.addresses) && service.addresses.length > 0
|
|
71
71
|
? service.addresses[0]
|
|
72
72
|
: (service.host ?? "localhost");
|
package/src/git-collaboration.ts
CHANGED
|
@@ -4,9 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import type { PluginLogger } from "../api.js";
|
|
6
6
|
import type { GitRepoState, PluginConfig, RepoSyncInfo } from "./types.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveTeamClawWorkspaceDir } from "./openclaw-workspace.js";
|
|
8
8
|
|
|
9
9
|
const TEAMCLAW_IMPORT_REF_PREFIX = "refs/teamclaw/imports";
|
|
10
|
+
const BUNDLE_IMPORT_MAX_RETRIES = 3;
|
|
11
|
+
const BUNDLE_IMPORT_RETRY_DELAY_MS = 2_000;
|
|
10
12
|
const TEAMCLAW_RUNTIME_EXCLUDES = [
|
|
11
13
|
".openclaw/",
|
|
12
14
|
".clawhub/",
|
|
@@ -51,7 +53,7 @@ export async function ensureControllerGitRepo(
|
|
|
51
53
|
config: PluginConfig,
|
|
52
54
|
logger: PluginLogger,
|
|
53
55
|
): Promise<GitRepoState | null> {
|
|
54
|
-
const workspaceDir =
|
|
56
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
55
57
|
return await withRepoLock(workspaceDir, async () => ensureControllerGitRepoUnlocked(config, logger, workspaceDir));
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -135,7 +137,7 @@ export async function exportControllerGitBundle(
|
|
|
135
137
|
config: PluginConfig,
|
|
136
138
|
logger: PluginLogger,
|
|
137
139
|
): Promise<{ repo: GitRepoState; data: Buffer; filename: string }> {
|
|
138
|
-
const workspaceDir =
|
|
140
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
139
141
|
return await withRepoLock(workspaceDir, async () => {
|
|
140
142
|
const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
|
|
141
143
|
if (!repo?.enabled) {
|
|
@@ -170,7 +172,7 @@ export async function importControllerGitBundle(
|
|
|
170
172
|
workerId?: string;
|
|
171
173
|
} = {},
|
|
172
174
|
): Promise<RepoImportResult> {
|
|
173
|
-
const workspaceDir =
|
|
175
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
174
176
|
return await withRepoLock(workspaceDir, async () => {
|
|
175
177
|
const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
|
|
176
178
|
if (!repo?.enabled) {
|
|
@@ -178,14 +180,21 @@ export async function importControllerGitBundle(
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
const refreshedBeforeImport = await readGitRepoState(config, repo.remoteReady);
|
|
183
|
+
let hadStashed = false;
|
|
181
184
|
if (refreshedBeforeImport.dirty) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
logger.info("Controller workspace has uncommitted changes; stashing before bundle import");
|
|
186
|
+
const stashResult = await tryGit(["stash", "--include-untracked"], { cwd: workspaceDir });
|
|
187
|
+
if (stashResult.exitCode === 0) {
|
|
188
|
+
hadStashed = true;
|
|
189
|
+
} else {
|
|
190
|
+
return {
|
|
191
|
+
merged: false,
|
|
192
|
+
fastForwarded: false,
|
|
193
|
+
alreadyUpToDate: false,
|
|
194
|
+
repo: refreshedBeforeImport,
|
|
195
|
+
message: "Controller workspace has uncommitted changes that cannot be stashed; refusing bundle import.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
189
198
|
}
|
|
190
199
|
|
|
191
200
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-import-"));
|
|
@@ -215,15 +224,21 @@ export async function importControllerGitBundle(
|
|
|
215
224
|
fastForwarded = false;
|
|
216
225
|
const mergeResult = await tryGit(["merge", "--no-edit", importRef], { cwd: workspaceDir });
|
|
217
226
|
if (mergeResult.exitCode !== 0) {
|
|
227
|
+
// Both ff-only and regular merge failed — worker history diverged from controller.
|
|
228
|
+
// Fall back to "theirs" strategy to preserve worker changes (the agent just finished work).
|
|
218
229
|
await abortMergeIfNeeded(workspaceDir);
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
const theirsResult = await tryGit(["merge", "--no-edit", "-X", "theirs", importRef], { cwd: workspaceDir });
|
|
231
|
+
if (theirsResult.exitCode !== 0) {
|
|
232
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
233
|
+
const currentRepo = await readGitRepoState(config, repo.remoteReady);
|
|
234
|
+
return {
|
|
235
|
+
merged: false,
|
|
236
|
+
fastForwarded: false,
|
|
237
|
+
alreadyUpToDate: false,
|
|
238
|
+
repo: currentRepo,
|
|
239
|
+
message: `Failed to merge worker bundle for task ${meta.taskId ?? "unknown"} even with theirs strategy: ${formatCommandError("git merge", theirsResult)}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
227
242
|
}
|
|
228
243
|
}
|
|
229
244
|
|
|
@@ -238,6 +253,12 @@ export async function importControllerGitBundle(
|
|
|
238
253
|
: `Imported worker bundle from ${meta.workerId ?? "worker"} with a merge commit.`,
|
|
239
254
|
};
|
|
240
255
|
} finally {
|
|
256
|
+
if (hadStashed) {
|
|
257
|
+
const popResult = await tryGit(["stash", "pop"], { cwd: workspaceDir });
|
|
258
|
+
if (popResult.exitCode !== 0) {
|
|
259
|
+
logger.warn(`Failed to restore stashed changes after bundle import: ${popResult.stderr?.trim() || "unknown error"}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
241
262
|
await tryGit(["update-ref", "-d", importRef], { cwd: workspaceDir });
|
|
242
263
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
243
264
|
// best-effort temp cleanup
|
|
@@ -252,7 +273,7 @@ export async function syncWorkerRepo(
|
|
|
252
273
|
controllerUrl: string,
|
|
253
274
|
repoInfo: RepoSyncInfo,
|
|
254
275
|
): Promise<RepoSyncResult> {
|
|
255
|
-
const workspaceDir =
|
|
276
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
256
277
|
return await withRepoLock(workspaceDir, async () => syncWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, workspaceDir));
|
|
257
278
|
}
|
|
258
279
|
|
|
@@ -285,7 +306,23 @@ async function syncWorkerRepoUnlocked(
|
|
|
285
306
|
|
|
286
307
|
const localRepo = await readGitRepoState(config, false);
|
|
287
308
|
if (localRepo.dirty) {
|
|
288
|
-
|
|
309
|
+
// Auto-commit leftover changes (e.g. from a prior failed task) instead of
|
|
310
|
+
// blocking sync. This mirrors publishWorkerRepoUnlocked's auto-commit and
|
|
311
|
+
// the controller's stash logic in importControllerGitBundle.
|
|
312
|
+
logger.info("Worker workspace has uncommitted changes; auto-committing before sync");
|
|
313
|
+
await runGit(["add", "-A"], { cwd: workspaceDir });
|
|
314
|
+
const commitResult = await tryGit(
|
|
315
|
+
["commit", "-m", "chore(teamclaw): auto-commit uncommitted changes before sync"],
|
|
316
|
+
{ cwd: workspaceDir },
|
|
317
|
+
);
|
|
318
|
+
if (commitResult.exitCode !== 0) {
|
|
319
|
+
// Commit can fail on a repo with no HEAD yet — fall back to stash
|
|
320
|
+
const stashResult = await tryGit(["stash", "--include-untracked"], { cwd: workspaceDir });
|
|
321
|
+
if (stashResult.exitCode !== 0) {
|
|
322
|
+
throw new Error("Worker workspace has uncommitted changes that cannot be committed or stashed");
|
|
323
|
+
}
|
|
324
|
+
logger.info("Stashed uncommitted changes (no HEAD commit yet)");
|
|
325
|
+
}
|
|
289
326
|
}
|
|
290
327
|
|
|
291
328
|
if (repoInfo.mode === "remote") {
|
|
@@ -300,7 +337,17 @@ async function syncWorkerRepoUnlocked(
|
|
|
300
337
|
await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/origin/${repoInfo.defaultBranch}`);
|
|
301
338
|
const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
302
339
|
if (mergeResult.exitCode !== 0) {
|
|
303
|
-
|
|
340
|
+
// Worker has local commits that diverge from origin — fall back to merge.
|
|
341
|
+
const regularMerge = await tryGit(["merge", "--no-edit", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
342
|
+
if (regularMerge.exitCode !== 0) {
|
|
343
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
344
|
+
const theirsMerge = await tryGit(["merge", "--no-edit", "-X", "theirs", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
345
|
+
if (theirsMerge.exitCode !== 0) {
|
|
346
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
347
|
+
logger.info("Worker repo has unrelated history; resetting to remote branch");
|
|
348
|
+
await runGit(["reset", "--hard", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
304
351
|
}
|
|
305
352
|
} else {
|
|
306
353
|
if (!repoInfo.bundleUrl) {
|
|
@@ -321,7 +368,21 @@ async function syncWorkerRepoUnlocked(
|
|
|
321
368
|
await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/teamclaw/${repoInfo.defaultBranch}`);
|
|
322
369
|
const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
323
370
|
if (mergeResult.exitCode !== 0) {
|
|
324
|
-
|
|
371
|
+
// Worker has local commits that diverge from controller — fall back to merge.
|
|
372
|
+
const regularMerge = await tryGit(["merge", "--no-edit", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
373
|
+
if (regularMerge.exitCode !== 0) {
|
|
374
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
375
|
+
const theirsMerge = await tryGit(["merge", "--no-edit", "-X", "theirs", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
376
|
+
if (theirsMerge.exitCode !== 0) {
|
|
377
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
378
|
+
// Final fallback: worker history is completely unrelated (e.g. fresh
|
|
379
|
+
// container with auto-committed workspace files). Adopt the
|
|
380
|
+
// controller's branch wholesale — this is safe because the
|
|
381
|
+
// controller is the source of truth for repo state.
|
|
382
|
+
logger.info("Worker repo has unrelated history; resetting to controller branch");
|
|
383
|
+
await runGit(["reset", "--hard", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
325
386
|
}
|
|
326
387
|
} finally {
|
|
327
388
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
@@ -348,7 +409,7 @@ export async function publishWorkerRepo(
|
|
|
348
409
|
role?: string;
|
|
349
410
|
},
|
|
350
411
|
): Promise<RepoPublishResult> {
|
|
351
|
-
const workspaceDir =
|
|
412
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
352
413
|
return await withRepoLock(workspaceDir, async () => publishWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, meta, workspaceDir));
|
|
353
414
|
}
|
|
354
415
|
|
|
@@ -421,32 +482,51 @@ async function publishWorkerRepoUnlocked(
|
|
|
421
482
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-publish-"));
|
|
422
483
|
const bundlePath = path.join(tempDir, "worker.bundle");
|
|
423
484
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
485
|
+
for (let attempt = 0; attempt <= BUNDLE_IMPORT_MAX_RETRIES; attempt++) {
|
|
486
|
+
if (attempt > 0) {
|
|
487
|
+
// Before retry, rebase local worker commits onto the latest controller state
|
|
488
|
+
// so the next bundle is based on the current controller HEAD.
|
|
489
|
+
await new Promise<void>((resolve) => setTimeout(resolve, BUNDLE_IMPORT_RETRY_DELAY_MS * attempt));
|
|
490
|
+
await tryRebaseWorkerOntoController(workspaceDir, repoInfo, controllerUrl, config, logger);
|
|
491
|
+
}
|
|
432
492
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
493
|
+
await runGit(["bundle", "create", bundlePath, repoInfo.defaultBranch], { cwd: workspaceDir });
|
|
494
|
+
const bundle = await fs.readFile(bundlePath);
|
|
495
|
+
const importUrl = new URL(resolveApiUrl(repoInfo.importUrl, controllerUrl));
|
|
496
|
+
importUrl.searchParams.set("taskId", meta.taskId);
|
|
497
|
+
importUrl.searchParams.set("workerId", meta.workerId);
|
|
498
|
+
if (meta.role) {
|
|
499
|
+
importUrl.searchParams.set("role", meta.role);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const res = await fetch(importUrl.toString(), {
|
|
503
|
+
method: "POST",
|
|
504
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
505
|
+
body: bundle,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (res.ok) {
|
|
509
|
+
const payload = await res.json() as { repo?: GitRepoState; message?: string };
|
|
510
|
+
return {
|
|
511
|
+
repo: payload.repo ?? await readGitRepoState(config, false),
|
|
512
|
+
published: true,
|
|
513
|
+
message: payload.message ?? `Imported bundle for task ${meta.taskId}.`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
438
516
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
517
|
+
// Only retry on 409 Conflict — other errors are not worth retrying.
|
|
518
|
+
if (res.status !== 409 || attempt === BUNDLE_IMPORT_MAX_RETRIES) {
|
|
519
|
+
const text = await res.text();
|
|
520
|
+
throw new Error(`Bundle import failed with status ${res.status}: ${text}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
logger.warn(
|
|
524
|
+
`Worker: bundle import for task ${meta.taskId} returned 409 (attempt ${attempt + 1}/${BUNDLE_IMPORT_MAX_RETRIES}); rebasing onto controller HEAD and retrying.`,
|
|
525
|
+
);
|
|
442
526
|
}
|
|
443
527
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
repo: payload.repo ?? await readGitRepoState(config, false),
|
|
447
|
-
published: true,
|
|
448
|
-
message: payload.message ?? `Imported bundle for task ${meta.taskId}.`,
|
|
449
|
-
};
|
|
528
|
+
// Should not reach here, but satisfy TypeScript.
|
|
529
|
+
throw new Error("Bundle import failed after all retries");
|
|
450
530
|
} finally {
|
|
451
531
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
|
|
452
532
|
// best-effort temp cleanup
|
|
@@ -458,7 +538,7 @@ export async function readGitRepoState(
|
|
|
458
538
|
config: PluginConfig,
|
|
459
539
|
remoteReady: boolean,
|
|
460
540
|
): Promise<GitRepoState> {
|
|
461
|
-
const workspaceDir =
|
|
541
|
+
const workspaceDir = resolveTeamClawWorkspaceDir();
|
|
462
542
|
const headCommit = await revParseOrEmpty(workspaceDir, "HEAD");
|
|
463
543
|
const headSummary = headCommit
|
|
464
544
|
? (await runGit(["log", "-1", "--pretty=%s"], { cwd: workspaceDir })).stdout.trim() || undefined
|
|
@@ -599,6 +679,77 @@ async function abortMergeIfNeeded(workspaceDir: string): Promise<void> {
|
|
|
599
679
|
await tryGit(["merge", "--abort"], { cwd: workspaceDir });
|
|
600
680
|
}
|
|
601
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Fetch the latest controller bundle and rebase local commits on top.
|
|
684
|
+
* Used as a recovery step when a bundle import fails with 409 Conflict.
|
|
685
|
+
*/
|
|
686
|
+
async function tryRebaseWorkerOntoController(
|
|
687
|
+
workspaceDir: string,
|
|
688
|
+
repoInfo: RepoSyncInfo,
|
|
689
|
+
controllerUrl: string,
|
|
690
|
+
config: PluginConfig,
|
|
691
|
+
logger: PluginLogger,
|
|
692
|
+
): Promise<void> {
|
|
693
|
+
try {
|
|
694
|
+
if (repoInfo.mode === "shared") {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Build the bundle URL based on mode
|
|
699
|
+
const bundleUrlSuffix = repoInfo.mode === "remote" ? repoInfo.remoteUrl : repoInfo.bundleUrl;
|
|
700
|
+
if (!bundleUrlSuffix) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (repoInfo.mode === "remote") {
|
|
705
|
+
// Remote mode: fetch from origin and rebase
|
|
706
|
+
await runGit(["fetch", "origin", repoInfo.defaultBranch], { cwd: workspaceDir });
|
|
707
|
+
const remoteRef = `refs/remotes/origin/${repoInfo.defaultBranch}`;
|
|
708
|
+
if (!await revParseOrEmpty(workspaceDir, remoteRef)) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const rebaseResult = await tryGit(["rebase", remoteRef], { cwd: workspaceDir });
|
|
712
|
+
if (rebaseResult.exitCode !== 0) {
|
|
713
|
+
await tryGit(["rebase", "--abort"], { cwd: workspaceDir });
|
|
714
|
+
// Fall back to merge if rebase fails
|
|
715
|
+
const mergeResult = await tryGit(["merge", "--no-edit", "-X", "theirs", remoteRef], { cwd: workspaceDir });
|
|
716
|
+
if (mergeResult.exitCode !== 0) {
|
|
717
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
718
|
+
logger.warn("Worker: failed to rebase or merge onto controller state; will retry bundle as-is.");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
// Bundle mode: download controller bundle, fetch it, and rebase
|
|
723
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-rebase-"));
|
|
724
|
+
const tempBundlePath = path.join(tempDir, "controller.bundle");
|
|
725
|
+
try {
|
|
726
|
+
const res = await fetch(resolveApiUrl(bundleUrlSuffix, controllerUrl));
|
|
727
|
+
if (!res.ok) {
|
|
728
|
+
logger.warn(`Worker: failed to download controller bundle for rebase (status ${res.status}); will retry as-is.`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
732
|
+
await fs.writeFile(tempBundlePath, buffer);
|
|
733
|
+
const remoteBranch = `refs/remotes/teamclaw/${repoInfo.defaultBranch}`;
|
|
734
|
+
await runGit(["fetch", tempBundlePath, `refs/heads/${repoInfo.defaultBranch}:${remoteBranch}`], { cwd: workspaceDir });
|
|
735
|
+
const rebaseResult = await tryGit(["rebase", remoteBranch], { cwd: workspaceDir });
|
|
736
|
+
if (rebaseResult.exitCode !== 0) {
|
|
737
|
+
await tryGit(["rebase", "--abort"], { cwd: workspaceDir });
|
|
738
|
+
const mergeResult = await tryGit(["merge", "--no-edit", "-X", "theirs", remoteBranch], { cwd: workspaceDir });
|
|
739
|
+
if (mergeResult.exitCode !== 0) {
|
|
740
|
+
await abortMergeIfNeeded(workspaceDir);
|
|
741
|
+
logger.warn("Worker: failed to rebase or merge onto controller state; will retry bundle as-is.");
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
logger.warn(`Worker: rebase onto controller failed: ${err instanceof Error ? err.message : String(err)}; will retry bundle as-is.`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
602
753
|
async function pathExists(filePath: string): Promise<boolean> {
|
|
603
754
|
try {
|
|
604
755
|
await fs.access(filePath);
|
package/src/identity.ts
CHANGED
|
@@ -89,8 +89,18 @@ export class IdentityManager {
|
|
|
89
89
|
|
|
90
90
|
const roleDef = getRole(this.config.role);
|
|
91
91
|
const workerId = requestedWorkerId ?? generateId();
|
|
92
|
-
const
|
|
93
|
-
|
|
92
|
+
const controllerHost = new URL(controllerUrl).hostname;
|
|
93
|
+
// Prefer explicit override (set by provisioner for Docker/K8s workers),
|
|
94
|
+
// then detected IP (works for bridge-network containers and real hosts),
|
|
95
|
+
// then os.hostname() as last resort.
|
|
96
|
+
const advertisedHost =
|
|
97
|
+
process.env.TEAMCLAW_ADVERTISE_HOST?.trim() ||
|
|
98
|
+
getLocalIp(controllerHost) ||
|
|
99
|
+
os.hostname();
|
|
100
|
+
const advertisedPort = process.env.TEAMCLAW_ADVERTISE_PORT?.trim()
|
|
101
|
+
? Number(process.env.TEAMCLAW_ADVERTISE_PORT.trim())
|
|
102
|
+
: this.config.port;
|
|
103
|
+
const workerUrl = `http://${advertisedHost}:${advertisedPort}`;
|
|
94
104
|
|
|
95
105
|
const registration = createRegistrationRequest(
|
|
96
106
|
workerId,
|
|
@@ -223,6 +223,7 @@ export function normalizeWorkerTaskResultContract(raw: unknown): WorkerTaskResul
|
|
|
223
223
|
followUps: normalizeResultFollowUps(input.followUps),
|
|
224
224
|
questions: normalizeContractStringList(input.questions),
|
|
225
225
|
notes: normalizeOptionalContractText(input.notes),
|
|
226
|
+
discoveredPatterns: normalizeContractStringList(input.discoveredPatterns),
|
|
226
227
|
};
|
|
227
228
|
}
|
|
228
229
|
|
|
@@ -315,7 +316,12 @@ function normalizeResultDeliverables(raw: unknown): WorkerTaskResultDeliverable[
|
|
|
315
316
|
: "note";
|
|
316
317
|
const value = typeof entry.value === "string" ? entry.value.trim() : "";
|
|
317
318
|
const summary = normalizeOptionalContractText(entry.summary);
|
|
318
|
-
|
|
319
|
+
const artifactType = typeof entry.artifactType === "string" ? entry.artifactType as WorkerTaskResultDeliverable["artifactType"] : undefined;
|
|
320
|
+
const previewCommand = normalizeOptionalContractText(entry.previewCommand);
|
|
321
|
+
const previewCwd = normalizeOptionalContractText(entry.previewCwd);
|
|
322
|
+
const previewReadyPath = normalizeOptionalContractText(entry.previewReadyPath);
|
|
323
|
+
const liveUrl = normalizeOptionalContractText(entry.liveUrl);
|
|
324
|
+
return value ? { kind, value, summary, artifactType, previewCommand, previewCwd, previewReadyPath, liveUrl } : null;
|
|
319
325
|
})
|
|
320
326
|
.filter((entry): entry is WorkerTaskResultDeliverable => !!entry);
|
|
321
327
|
}
|
|
@@ -426,6 +432,30 @@ function extractQuestionOrBulletLines(text: string, maxItems: number, matcher?:
|
|
|
426
432
|
return Array.from(new Set(lines.map((line) => summarizeContractText(line, 220)))).slice(0, maxItems);
|
|
427
433
|
}
|
|
428
434
|
|
|
435
|
+
const WEB_HTML_FILENAMES = new Set(["index.html", "app.html", "main.html", "home.html"]);
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Default preview command used when the worker does not provide one.
|
|
439
|
+
* This is a minimal static file server — the worker (LLM) is responsible
|
|
440
|
+
* for providing the real, framework-appropriate command via previewCommand
|
|
441
|
+
* in its result contract.
|
|
442
|
+
*/
|
|
443
|
+
function inferPreviewCommand(_cwd: string, _resultText: string, _filePaths: string[]): string {
|
|
444
|
+
return "npx -y serve -l {PORT}";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isWebHtmlPath(filePath: string): boolean {
|
|
448
|
+
const segments = filePath.replace(/\\/gu, "/").split("/");
|
|
449
|
+
const filename = segments[segments.length - 1] ?? "";
|
|
450
|
+
if (!filename.toLowerCase().endsWith(".html") && !filename.toLowerCase().endsWith(".htm")) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
if (filename.startsWith(".") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.")) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
429
459
|
function inferResultDeliverables(result: string, error?: string): WorkerTaskResultDeliverable[] {
|
|
430
460
|
if (error) {
|
|
431
461
|
return [{
|
|
@@ -436,10 +466,31 @@ function inferResultDeliverables(result: string, error?: string): WorkerTaskResu
|
|
|
436
466
|
}
|
|
437
467
|
|
|
438
468
|
const deliverables: WorkerTaskResultDeliverable[] = [];
|
|
439
|
-
|
|
469
|
+
let webAppInferred = false;
|
|
470
|
+
const pathMatches = Array.from(result.matchAll(/(?:^|[\s:(`])([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+)/g))
|
|
440
471
|
.map((match) => match[1])
|
|
441
472
|
.filter(Boolean);
|
|
442
|
-
|
|
473
|
+
const uniquePaths = Array.from(new Set(pathMatches)).slice(0, 5);
|
|
474
|
+
for (const filePath of uniquePaths) {
|
|
475
|
+
if (!webAppInferred && isWebHtmlPath(filePath)) {
|
|
476
|
+
const segments = filePath.replace(/\\/gu, "/").split("/");
|
|
477
|
+
const filename = segments[segments.length - 1] ?? "";
|
|
478
|
+
const dirname = segments.length > 1 ? segments.slice(0, -1).join("/") : ".";
|
|
479
|
+
const isTopLevelHtml = WEB_HTML_FILENAMES.has(filename);
|
|
480
|
+
if (isTopLevelHtml || filename.toLowerCase().endsWith(".html")) {
|
|
481
|
+
webAppInferred = true;
|
|
482
|
+
deliverables.push({
|
|
483
|
+
kind: "directory",
|
|
484
|
+
value: dirname,
|
|
485
|
+
summary: `Web application at ${dirname}`,
|
|
486
|
+
artifactType: "web-app",
|
|
487
|
+
previewCommand: inferPreviewCommand(dirname, result, uniquePaths),
|
|
488
|
+
previewCwd: dirname,
|
|
489
|
+
previewReadyPath: "/",
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
443
494
|
deliverables.push({
|
|
444
495
|
kind: "file",
|
|
445
496
|
value: filePath,
|
|
@@ -457,3 +508,128 @@ function inferResultDeliverables(result: string, error?: string): WorkerTaskResu
|
|
|
457
508
|
summary: "Backfilled from the worker's final reply.",
|
|
458
509
|
}];
|
|
459
510
|
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Enrich existing deliverables with preview fields (artifactType, previewCommand, etc.)
|
|
514
|
+
* by inferring from the raw result text.
|
|
515
|
+
*
|
|
516
|
+
* This function only enriches when the worker did NOT already provide these fields.
|
|
517
|
+
* When the worker (LLM) submits a proper result contract with previewCommand and
|
|
518
|
+
* artifactType, we trust it completely — it knows its own code better than we do.
|
|
519
|
+
*
|
|
520
|
+
* Returns the contract with enriched deliverables if any enrichment happened, or null
|
|
521
|
+
* if no enrichment was needed.
|
|
522
|
+
*/
|
|
523
|
+
export function enrichDeliverablesWithPreviewInference(
|
|
524
|
+
contract: WorkerTaskResultContract,
|
|
525
|
+
resultText: string,
|
|
526
|
+
): WorkerTaskResultContract | null {
|
|
527
|
+
const { deliverables } = contract;
|
|
528
|
+
const existingWebApp = deliverables.find((d) => d.artifactType === "web-app");
|
|
529
|
+
if (existingWebApp) {
|
|
530
|
+
// Worker already provided a web-app with a real previewCommand — trust it.
|
|
531
|
+
if (existingWebApp.previewCommand?.trim()) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
// Worker declared web-app but no command — try to provide a fallback command.
|
|
535
|
+
const cwd = existingWebApp.previewCwd?.trim();
|
|
536
|
+
if (cwd && cwd !== "." && cwd !== "./") {
|
|
537
|
+
// Has a real cwd but no command — provide generic static serve fallback
|
|
538
|
+
existingWebApp.previewCommand = inferPreviewCommand(cwd, resultText, deliverables.map((d) => d.value ?? ""));
|
|
539
|
+
return { ...contract, deliverables };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Strategy 1: Extract file paths from result text to detect HTML files
|
|
544
|
+
const allSources = [resultText, ...deliverables.map((d) => d.summary ?? "")];
|
|
545
|
+
const pathMatches = Array.from(
|
|
546
|
+
allSources.join("\n").matchAll(/(?:^|[\s:(,`])([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+)/g),
|
|
547
|
+
)
|
|
548
|
+
.map((match) => match[1])
|
|
549
|
+
.filter(Boolean);
|
|
550
|
+
|
|
551
|
+
for (const filePath of Array.from(new Set(pathMatches)).slice(0, 5)) {
|
|
552
|
+
if (isWebHtmlPath(filePath)) {
|
|
553
|
+
const segments = filePath.replace(/\\/gu, "/").split("/");
|
|
554
|
+
const filename = segments[segments.length - 1] ?? "";
|
|
555
|
+
const dirname = segments.length > 1 ? segments.slice(0, -1).join("/") : ".";
|
|
556
|
+
if (!filename.toLowerCase().endsWith(".html") && !filename.toLowerCase().endsWith(".htm")) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const allDeliverablePaths = deliverables.map((d) => d.value ?? "");
|
|
560
|
+
const matchingIndex = deliverables.findIndex((d) =>
|
|
561
|
+
d.kind === "directory"
|
|
562
|
+
&& filePath.replace(/\\/gu, "/").startsWith(
|
|
563
|
+
d.value?.replace(/\\/gu, "/").replace(/\/$/u, ""),
|
|
564
|
+
),
|
|
565
|
+
);
|
|
566
|
+
if (matchingIndex >= 0) {
|
|
567
|
+
deliverables[matchingIndex] = {
|
|
568
|
+
...deliverables[matchingIndex],
|
|
569
|
+
artifactType: "web-app",
|
|
570
|
+
previewCommand: inferPreviewCommand(dirname, resultText, allDeliverablePaths),
|
|
571
|
+
previewCwd: dirname,
|
|
572
|
+
previewReadyPath: "/",
|
|
573
|
+
summary: deliverables[matchingIndex].summary || `Web application at ${dirname}`,
|
|
574
|
+
};
|
|
575
|
+
} else {
|
|
576
|
+
deliverables.push({
|
|
577
|
+
kind: "directory",
|
|
578
|
+
value: dirname,
|
|
579
|
+
summary: `Web application at ${dirname}`,
|
|
580
|
+
artifactType: "web-app",
|
|
581
|
+
previewCommand: inferPreviewCommand(dirname, resultText, allDeliverablePaths),
|
|
582
|
+
previewCwd: dirname,
|
|
583
|
+
previewReadyPath: "/",
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return { ...contract, deliverables };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Strategy 2: If result text mentions HTML/web/blog/site keywords and there's a
|
|
591
|
+
// directory deliverable without artifactType, infer web-app from the directory itself.
|
|
592
|
+
const webKeywords = /\b(html|web\s*app|web\s*site|blog|index\.html|app\.html|homepage)\b/i;
|
|
593
|
+
const hasWebKeywords = webKeywords.test(resultText) || deliverables.some((d) => webKeywords.test(d.summary ?? ""));
|
|
594
|
+
|
|
595
|
+
if (hasWebKeywords) {
|
|
596
|
+
const dirDeliverable = deliverables.find(
|
|
597
|
+
(d) => d.kind === "directory" && !d.artifactType && d.value,
|
|
598
|
+
);
|
|
599
|
+
if (dirDeliverable) {
|
|
600
|
+
const cwd = dirDeliverable.value.replace(/\/$/u, "");
|
|
601
|
+
const allDeliverablePaths = deliverables.map((d) => d.value ?? "");
|
|
602
|
+
deliverables[deliverables.indexOf(dirDeliverable)] = {
|
|
603
|
+
...dirDeliverable,
|
|
604
|
+
artifactType: "web-app",
|
|
605
|
+
previewCommand: inferPreviewCommand(cwd, resultText, allDeliverablePaths),
|
|
606
|
+
previewCwd: cwd,
|
|
607
|
+
previewReadyPath: "/",
|
|
608
|
+
summary: dirDeliverable.summary || `Web application at ${cwd}`,
|
|
609
|
+
};
|
|
610
|
+
return { ...contract, deliverables };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Strategy 3: Extract directory paths that contain HTML files from result text
|
|
615
|
+
// (e.g. "blog/index.html" → directory "blog"). This handles cases where deliverables
|
|
616
|
+
// are all "note" type but result text references HTML files in directories.
|
|
617
|
+
const dirHtmlPattern = /([A-Za-z0-9_.-]+\/)[A-Za-z0-9_.-]+\.html\b/g;
|
|
618
|
+
const dirMatches = Array.from(resultText.matchAll(dirHtmlPattern)).map((m) => m[1].replace(/\/$/u, ""));
|
|
619
|
+
const uniqueDirs = Array.from(new Set(dirMatches));
|
|
620
|
+
if (uniqueDirs.length > 0 && hasWebKeywords) {
|
|
621
|
+
const dirname = uniqueDirs[0];
|
|
622
|
+
deliverables.push({
|
|
623
|
+
kind: "directory",
|
|
624
|
+
value: dirname,
|
|
625
|
+
summary: `Web application at ${dirname}`,
|
|
626
|
+
artifactType: "web-app",
|
|
627
|
+
previewCommand: inferPreviewCommand(dirname, resultText, [dirname]),
|
|
628
|
+
previewCwd: dirname,
|
|
629
|
+
previewReadyPath: "/",
|
|
630
|
+
});
|
|
631
|
+
return { ...contract, deliverables };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return null;
|
|
635
|
+
}
|