Skip to content

Commit f6ef78f

Browse files
hugocasaclaude
andcommitted
feat: set up a project on add (scaffold + analyze with Claude), observably
Adding a repo that has no .webmux.yaml now runs setup automatically instead of registering an unconfigured project: scaffold a starter .webmux.yaml → analyze the repo with Claude to fill it in → register. Each phase is surfaced in the dashboard and the CLI ("Creating .webmux.yaml" → "Analyzing project structure with Claude" → ready). Backend: - ProjectInitTracker + runProjectInit (services/project-init-service.ts): hub-level, keyed by repo path; phases creating_config → analyzing → ready, or failed. Analysis is best-effort (skipped if no agent on PATH, non-fatal on error) so the starter config always ships. Terminal states TTL-expire. - apiAddProject: repos with config (or already served) register immediately; repos without kick off the async job and return { initializing: true }. New GET /api/projects/init reports progress. The setup uses the lifted init-authoring helpers with a timeout so a hung agent can't stall it. Contract: AddProjectResponse ({ initializing, path, project }), ProjectInit phase/state schemas, and the projectInits endpoint. Frontend: setUpProject() POSTs then polls projectInits, reporting phases; EmptyProjects + ProjectSwitcher show the steps and open the project when ready. CLI: `webmux project add` on an unconfigured repo prints the same phases via a DI-seamed awaitProjectSetup(), then the added line. Parity with the UI. Tests cover the tracker, orchestration (skip/best-effort/failure), and the CLI poller; init-authoring gains a run timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 542edb8 commit f6ef78f

14 files changed

Lines changed: 581 additions & 45 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
ProjectInitTracker,
4+
runProjectInit,
5+
type ProjectInitDeps,
6+
} from "../services/project-init-service";
7+
8+
describe("ProjectInitTracker", () => {
9+
it("upserts phase transitions and carries prefix/name into ready", () => {
10+
const tracker = new ProjectInitTracker();
11+
tracker.set("/repo/a", { phase: "creating_config" });
12+
expect(tracker.isActive("/repo/a")).toBe(true);
13+
14+
tracker.set("/repo/a", { phase: "analyzing" });
15+
tracker.set("/repo/a", { phase: "ready", prefix: "a", name: "A" });
16+
17+
const state = tracker.get("/repo/a");
18+
expect(state).toMatchObject({ phase: "ready", prefix: "a", name: "A", error: null });
19+
expect(tracker.isActive("/repo/a")).toBe(false);
20+
});
21+
22+
it("evicts terminal entries past the TTL but keeps in-flight ones", () => {
23+
let clock = 1000;
24+
const tracker = new ProjectInitTracker({ ttlMs: 100, now: () => clock });
25+
26+
tracker.set("/repo/done", { phase: "ready", prefix: "done", name: "Done" });
27+
tracker.set("/repo/busy", { phase: "analyzing" });
28+
29+
clock = 1050; // within TTL — both visible
30+
expect(tracker.list().map((s) => s.path).sort()).toEqual(["/repo/busy", "/repo/done"]);
31+
32+
clock = 1200; // terminal entry now past TTL; in-flight stays
33+
expect(tracker.list().map((s) => s.path)).toEqual(["/repo/busy"]);
34+
});
35+
});
36+
37+
function makeDeps(overrides: Partial<ProjectInitDeps> & { calls?: string[] } = {}): ProjectInitDeps {
38+
const calls = overrides.calls ?? [];
39+
return {
40+
analyzerAvailable: overrides.analyzerAvailable ?? ((): boolean => true),
41+
scaffold: overrides.scaffold ?? (async (): Promise<void> => { calls.push("scaffold"); }),
42+
analyze: overrides.analyze ?? (async (): Promise<void> => { calls.push("analyze"); }),
43+
register: overrides.register ?? ((): { prefix: string; name: string } => {
44+
calls.push("register");
45+
return { prefix: "a", name: "A" };
46+
}),
47+
};
48+
}
49+
50+
describe("runProjectInit", () => {
51+
it("scaffolds, analyzes, registers, then marks ready (in order)", async () => {
52+
const calls: string[] = [];
53+
const tracker = new ProjectInitTracker();
54+
await runProjectInit(tracker, "/repo/a", makeDeps({ calls }));
55+
56+
expect(calls).toEqual(["scaffold", "analyze", "register"]);
57+
expect(tracker.get("/repo/a")).toMatchObject({ phase: "ready", prefix: "a", name: "A" });
58+
});
59+
60+
it("skips analysis when no analyzer is available but still registers", async () => {
61+
const calls: string[] = [];
62+
const tracker = new ProjectInitTracker();
63+
await runProjectInit(tracker, "/repo/a", makeDeps({ calls, analyzerAvailable: () => false }));
64+
65+
expect(calls).toEqual(["scaffold", "register"]);
66+
expect(tracker.get("/repo/a")?.phase).toBe("ready");
67+
});
68+
69+
it("registers anyway when analysis throws (best-effort enrichment)", async () => {
70+
const calls: string[] = [];
71+
const tracker = new ProjectInitTracker();
72+
await runProjectInit(tracker, "/repo/a", makeDeps({
73+
calls,
74+
analyze: async () => { throw new Error("claude blew up"); },
75+
}));
76+
77+
expect(calls).toEqual(["scaffold", "register"]);
78+
expect(tracker.get("/repo/a")?.phase).toBe("ready");
79+
});
80+
81+
it("marks failed and does not register when scaffold throws", async () => {
82+
const calls: string[] = [];
83+
const tracker = new ProjectInitTracker();
84+
await runProjectInit(tracker, "/repo/a", makeDeps({
85+
calls,
86+
scaffold: async () => { throw new Error("cannot write .webmux.yaml"); },
87+
}));
88+
89+
expect(calls).toEqual([]);
90+
expect(tracker.get("/repo/a")).toMatchObject({ phase: "failed", error: "cannot write .webmux.yaml" });
91+
});
92+
});

backend/src/server.ts

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ import {
5454
type ProjectConfig,
5555
} from "./adapters/config";
5656
import { jsonResponse, errorResponse } from "./lib/http";
57+
import { which } from "./lib/shell";
58+
import {
59+
buildInitAgentCommand,
60+
buildInitPromptSpec,
61+
buildStarterTemplate,
62+
detectInitProjectContext,
63+
runInitAgentCommand,
64+
type InitAgent,
65+
} from "./services/init-authoring";
66+
import {
67+
ProjectInitTracker,
68+
runProjectInit,
69+
type ProjectInitDeps,
70+
} from "./services/project-init-service";
5771
import { isRecord, isStringArray } from "./lib/type-guards";
5872
import { parseJsonBody, parseParams, parseQuery } from "./api-validation";
5973
import { hasRecentDashboardActivity, touchDashboardActivity } from "./services/dashboard-activity";
@@ -118,7 +132,7 @@ import { isValidBranchName, isValidWorktreeName } from "./domain/policies";
118132
import { createWebmuxRuntime, type WebmuxRuntime } from "./runtime";
119133
import { createInstanceRegistry, type InstanceEntry } from "./adapters/instance-registry";
120134
import { createProjectsRegistry } from "./adapters/projects-registry";
121-
import { ProjectManager, type ProjectLoopController } from "./services/project-manager";
135+
import { ProjectManager, type ManagedProject, type ProjectLoopController } from "./services/project-manager";
122136

123137
const PORT = parseInt(Bun.env.PORT || "5111", 10);
124138
const STATIC_DIR = Bun.env.WEBMUX_STATIC_DIR || "";
@@ -2328,6 +2342,9 @@ const apps = new Map<string, ProjectApp>();
23282342
// removed and track whether a project is currently being viewed (`active`).
23292343
const openSockets = new Map<string, Set<ServerWebSocket<WsData>>>();
23302344
const instanceRegistry = createInstanceRegistry();
2345+
// Tracks on-add project setups (scaffold config → analyze with Claude → ready)
2346+
// so the UI + CLI can show progress. Hub-level: one per machine, keyed by repo.
2347+
const projectInitTracker = new ProjectInitTracker();
23312348
let manager: ProjectManager;
23322349
let server: Server<WsData>;
23332350
let BOUND_PORT = 0;
@@ -2341,31 +2358,84 @@ async function catchingRoute(label: string, fn: () => Promise<Response>): Promis
23412358
}
23422359
}
23432360

2361+
function toProjectSummary(p: ManagedProject): { prefix: string; name: string; path: string; active: boolean } {
2362+
return { prefix: p.prefix, name: p.entry.name, path: p.entry.path, active: p.active };
2363+
}
2364+
23442365
function apiListProjects(): Response {
2345-
return jsonResponse({
2346-
projects: manager.list().map((p) => ({
2347-
prefix: p.prefix,
2348-
name: p.entry.name,
2349-
path: p.entry.path,
2350-
active: p.active,
2351-
})),
2352-
});
2366+
return jsonResponse({ projects: manager.list().map(toProjectSummary) });
23532367
}
23542368

2369+
/** A repo is a webmux project once it has a config file. */
2370+
function hasProjectConfig(root: string): boolean {
2371+
return existsSync(join(root, ".webmux.yaml")) || existsSync(join(root, ".webmux.local.yaml"));
2372+
}
2373+
2374+
/** Agent used to author config on setup: prefer Claude, fall back to Codex. */
2375+
function authoringAgent(): InitAgent {
2376+
return which("claude") ? "claude" : "codex";
2377+
}
2378+
2379+
const ANALYZE_TIMEOUT_MS = 120_000;
2380+
2381+
/** I/O for the on-add setup flow. The server is local, so it spawns the agent
2382+
* with the repo as cwd to scaffold + flesh out `.webmux.yaml`, then registers. */
2383+
const projectInitDeps: ProjectInitDeps = {
2384+
analyzerAvailable: () => which("claude") || which("codex"),
2385+
scaffold: async (root) => {
2386+
const context = detectInitProjectContext(root, authoringAgent());
2387+
await Bun.write(join(root, ".webmux.yaml"), buildStarterTemplate(context));
2388+
},
2389+
analyze: async (root) => {
2390+
const agent = authoringAgent();
2391+
const spec = buildInitAgentCommand(agent, buildInitPromptSpec(detectInitProjectContext(root, agent)));
2392+
await runInitAgentCommand(spec, root, { timeoutMs: ANALYZE_TIMEOUT_MS });
2393+
},
2394+
register: (root) => {
2395+
const project = manager.add(root);
2396+
reloadRoutes();
2397+
return { prefix: project.prefix, name: project.entry.name };
2398+
},
2399+
};
2400+
23552401
async function apiAddProject(req: BunRequest): Promise<Response> {
23562402
const body: unknown = await req.json().catch(() => null);
23572403
if (!isRecord(body) || typeof body.path !== "string" || body.path.trim() === "") {
23582404
return errorResponse("Request body must be { path: string }", 400);
23592405
}
23602406
const inputPath = body.path.trim();
23612407
if (!isGitRepo(inputPath)) return errorResponse(`Not a git repository: ${inputPath}`, 400);
2362-
const project = manager.add(inputPath);
2363-
reloadRoutes();
2408+
const root = projectRoot(inputPath);
2409+
2410+
// Already served, or already a webmux project → register now, no setup job.
2411+
const existing = manager.getByPath(root);
2412+
if (existing) {
2413+
return jsonResponse({ initializing: false, path: root, project: toProjectSummary(existing) });
2414+
}
2415+
if (hasProjectConfig(root)) {
2416+
const project = manager.add(root);
2417+
reloadRoutes();
2418+
return jsonResponse({ initializing: false, path: root, project: toProjectSummary(project) });
2419+
}
2420+
2421+
// No config → scaffold + analyze + register asynchronously; the client polls
2422+
// `projectInits` for progress and the resulting prefix.
2423+
if (!projectInitTracker.isActive(root)) {
2424+
projectInitTracker.set(root, { phase: "creating_config" });
2425+
void runProjectInit(projectInitTracker, root, projectInitDeps);
2426+
}
2427+
return jsonResponse({ initializing: true, path: root, project: null });
2428+
}
2429+
2430+
function apiListProjectInits(): Response {
23642431
return jsonResponse({
2365-
prefix: project.prefix,
2366-
name: project.entry.name,
2367-
path: project.entry.path,
2368-
active: project.active,
2432+
inits: projectInitTracker.list().map((s) => ({
2433+
path: s.path,
2434+
phase: s.phase,
2435+
prefix: s.prefix,
2436+
name: s.name,
2437+
error: s.error,
2438+
})),
23692439
});
23702440
}
23712441

@@ -2455,6 +2525,9 @@ function buildServeRoutes(): ProjectRoutes {
24552525
GET: () => apiListProjects(),
24562526
POST: (req) => catchingRoute("POST /api/projects", () => apiAddProject(req)),
24572527
},
2528+
[apiPaths.projectInits]: {
2529+
GET: () => apiListProjectInits(),
2530+
},
24582531
[apiPaths.migrateProjects]: {
24592532
POST: (req) => catchingRoute("POST /api/projects/migrate", () => apiMigrateProjects(req)),
24602533
},

backend/src/services/init-authoring.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export interface InitAgentRunResult {
4141

4242
export interface InitAgentRunHandlers {
4343
onEvent?: (event: InitAgentStreamEvent) => void;
44+
/** Kill the agent if it runs longer than this (ms). Used server-side so a
45+
* hung analysis can't stall project setup forever. */
46+
timeoutMs?: number;
4447
}
4548

4649
interface InitAgentStreamState {
@@ -506,11 +509,31 @@ export async function runInitAgentCommand(
506509
stderr: "pipe",
507510
});
508511

509-
const [exitCode, stdoutResult, stderr] = await Promise.all([
510-
proc.exited,
511-
consumeStructuredStream(proc.stdout, spec.agent, handlers.onEvent),
512-
consumeRawStream(proc.stderr),
513-
]);
512+
const timeout = handlers.timeoutMs
513+
? setTimeout(() => {
514+
try { proc.kill(); } catch { /* already exited */ }
515+
}, handlers.timeoutMs)
516+
: null;
517+
518+
try {
519+
const [exitCode, stdoutResult, stderr] = await Promise.all([
520+
proc.exited,
521+
consumeStructuredStream(proc.stdout, spec.agent, handlers.onEvent),
522+
consumeRawStream(proc.stderr),
523+
]);
524+
525+
return finalizeAgentRun(spec, exitCode, stdoutResult, stderr);
526+
} finally {
527+
if (timeout) clearTimeout(timeout);
528+
}
529+
}
530+
531+
function finalizeAgentRun(
532+
spec: InitAgentCommandSpec,
533+
exitCode: number,
534+
stdoutResult: { raw: string; assistantText: string },
535+
stderr: string,
536+
): InitAgentRunResult {
514537

515538
let summary = stdoutResult.assistantText.trim();
516539
if (spec.summaryPath && existsSync(spec.summaryPath)) {

0 commit comments

Comments
 (0)