feat(daemon): POST /api/rigs/:rigId/cmux/launch + CmuxLayoutService wiring (slice 24 Checkpoint C)

Per-rig CMUX workspace launcher route. Coordination:
  1. rigRepo.getRig(rigId)         → 404 rig_not_found if missing
  2. cmuxAdapter.isAvailable()     → 503 cmux_unavailable if not connected
  3. nodeInventoryFn(rigId)        → map logicalId → canonicalSessionName
  4. order agents by rig.nodes DB order (ORDER BY created_at =
     pod-then-member from rig spec per README §52)
  5. filter to running agents (has canonicalSessionName)
                                   → 412 rig_not_running if none running
  6. CmuxLayoutService.chunkAgents() into MAX_PER_WORKSPACE chunks
  7. pickNonCollidingName() — auto-append -2/-3/... per README R2
     when the rig's name or a generated suffix already exists in cmux
  8. cmuxLayoutService.buildWorkspace() per chunk sequentially
                                   → 500 build_workspace_failed with
                                     partial: workspaces[] on mid-flight failure
  9. 200 { ok, workspaces: Array<{ name, agents, blanks }> }

Honest 3-part error messages per banked building-agent-software
doctrine:
- "Rig '<id>' not found — can't launch cmux workspace — try: rig ps"
- "cmux is not available on this host — can't launch workspace —
   install cmux from https://cmux.io and run: cmux ping"
- "Rig '<name>' has no running tmux sessions — can't attach to
   anything — run: rig up <name>"

Wiring (packages/daemon/src/server.ts):
- import rigCmuxRoutes + CmuxLayoutService + getNodeInventory
- Per-request context: c.set("cmuxLayoutService", new
  CmuxLayoutService(deps.cmuxAdapter)) and c.set("nodeInventoryFn",
  (rigId) => getNodeInventory(deps.rigRepo.db, rigId))
- app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes)

Tests: 9/9 route tests green (TDD-first at 324f9f9 → impl in this
commit). Cmux family total: 137/137 (CmuxAdapter + transport +
layout-service + route).

Typecheck: clean for new files (rig-cmux.ts + cmux-layout-service.ts);
server.ts typecheck passes after wiring.

SC-29 EXCEPTION declared: #10 (provisional)

This slice adds a new daemon endpoint POST /api/rigs/:rigId/cmux/launch
+ a new domain service CmuxLayoutService + four new RPC method wrappers
on CmuxAdapter routed through cmux's `rpc <method>` CLI subcommand.
Boundary: new endpoint surface + new service + new adapter methods +
new generic-RPC transport path. No new ConfigStore allowlist keys; no
new env vars; no schema migration; no new event type.

The cumulative ledger reference (best-effort at commit-time; confirm
at slice close against authoritative ledger):
- #1-4 kernel + shell-redesign-v1
- #5/#6 project-observability
- #8 plugin-primitive slice 3.3 (plugin-discovery + GET routes)
- #9 plugin-primitive slice 3.2 (vendored plugin assets + PluginVendorService)
- (no #10 declared yet that I can find in release-0.3.1 slices)
- This slice → provisional #10

Driver will confirm the exact number against the cumulative SC-29
ledger at slice close ACK packet authoring.

Carry-forward from prior checkpoints (non-blockers):
- rpc-help-gating in cmux-transport.ts buildCommand (QA scope)
- Live response-shape proof for cmux RPC methods (QA scope)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 21:39:07 -07:00
1 parent 324f9f907c
commit a18e99728f
2 files changed
+162

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
// Slice 24 — POST /api/rigs/:rigId/cmux/launch.
//
// Per-rig "Launch in CMUX" endpoint. Coordination:
// 1. rigRepo.getRig(rigId) → 404 if missing
// 2. cmuxAdapter.isAvailable() → 503 if cmux not connected
// 3. nodeInventoryFn(rigId) → map logicalId → canonicalSessionName
// 4. order agents by rig.nodes DB order (ORDER BY created_at = pod-then-member from spec)
// 5. filter to running (has canonicalSessionName) → 412 if none running
// 6. chunkAgents into MAX_PER_WORKSPACE-sized chunks
// 7. pick non-colliding workspace name per chunk
// (auto-append -2/-3/... per README R2 + multi-workspace handling)
// 8. cmuxLayoutService.buildWorkspace per chunk (sequential)
// → 500 with partial info if any buildWorkspace fails mid-flight
// 9. 200 with { ok, workspaces[] }
//
// Deps wired via Hono context (see server.ts deps wiring block).
import { Hono } from "hono";
import type { RigRepository } from "../domain/rig-repository.js";
import type { CmuxAdapter } from "../adapters/cmux.js";
import { CmuxLayoutService } from "../domain/cmux-layout-service.js";
export const rigCmuxRoutes = new Hono();
interface NodeInventoryStubEntry {
logicalId: string;
canonicalSessionName: string;
}
type NodeInventoryFn = (rigId: string) => NodeInventoryStubEntry[];
interface RigCmuxDeps {
rigRepo: RigRepository;
cmuxAdapter: CmuxAdapter;
cmuxLayoutService: CmuxLayoutService;
nodeInventoryFn: NodeInventoryFn;
}
function getDeps(c: { get: (key: string) => unknown }): RigCmuxDeps {
return {
rigRepo: c.get("rigRepo" as never) as RigRepository,
cmuxAdapter: c.get("cmuxAdapter" as never) as CmuxAdapter,
cmuxLayoutService: c.get("cmuxLayoutService" as never) as CmuxLayoutService,
nodeInventoryFn: c.get("nodeInventoryFn" as never) as NodeInventoryFn,
};
}
function pickNonCollidingName(baseName: string, existing: Set<string>): string {
if (!existing.has(baseName)) return baseName;
let suffix = 2;
while (existing.has(`${baseName}-${suffix}`)) suffix += 1;
return `${baseName}-${suffix}`;
}
rigCmuxRoutes.post("/launch", async (c) => {
const rigId = c.req.param("rigId")!;
const { rigRepo, cmuxAdapter, cmuxLayoutService, nodeInventoryFn } = getDeps(c);
const rigWithRelations = rigRepo.getRig(rigId);
if (!rigWithRelations) {
return c.json(
{
ok: false,
error: "rig_not_found",
message: `Rig "${rigId}" not found — can't launch cmux workspace — try: rig ps`,
},
404,
);
}
if (!cmuxAdapter.isAvailable()) {
return c.json(
{
ok: false,
error: "cmux_unavailable",
message:
"cmux is not available on this host — can't launch workspace — install cmux from https://cmux.io and run: cmux ping",
},
503,
);
}
const inventory = nodeInventoryFn(rigId);
const sessionByLogical = new Map(
inventory.map((e) => [e.logicalId, e.canonicalSessionName]),
);
// rig.nodes is in DB ORDER BY created_at, which corresponds to spec
// pod-then-member declaration order (pods created first, members
// within each pod in spec order). Use that as the deterministic
// agent ordering — no name-based sorting per README §52.
const orderedSessions: string[] = [];
for (const node of rigWithRelations.nodes) {
const session = sessionByLogical.get(node.logicalId);
if (session) orderedSessions.push(session);
}
if (orderedSessions.length === 0) {
const rigName = (rigWithRelations.rig as unknown as { name: string }).name;
return c.json(
{
ok: false,
error: "rig_not_running",
message: `Rig "${rigName}" has no running tmux sessions — can't attach to anything — run: rig up ${rigName}`,
},
412,
);
}
// Discover existing workspaces for collision-avoidance. If
// listWorkspaces fails, treat the existing set as empty — the
// operator can rename the resulting workspace if needed; better to
// ship cleanly than block on an enumeration failure.
const listResult = await cmuxAdapter.listWorkspaces();
const existingNames = new Set<string>(
listResult.ok ? listResult.data.map((w) => w.name) : [],
);
const chunks = CmuxLayoutService.chunkAgents(orderedSessions);
const baseName = (rigWithRelations.rig as unknown as { name: string }).name;
const workspaces: Array<{ name: string; agents: string[]; blanks: number }> = [];
for (let i = 0; i < chunks.length; i++) {
const desired = i === 0 ? baseName : `${baseName}-${i + 1}`;
const name = pickNonCollidingName(desired, existingNames);
existingNames.add(name);
const build = await cmuxLayoutService.buildWorkspace(name, undefined, chunks[i]!);
if (!build.ok) {
return c.json(
{
ok: false,
error: "build_workspace_failed",
message: build.message,
partial: workspaces,
},
500,
);
}
workspaces.push({
name: build.data.workspaceName,
agents: build.data.agents,
blanks: build.data.blanks,
});
}
return c.json({ ok: true, workspaces });
});
+14
View File
@@ -78,6 +78,9 @@ import { workflowRoutes } from "./routes/workflow.js";
import { missionControlRoutes } from "./routes/mission-control.js";
import { slicesRoutes } from "./routes/slices.js";
import { missionsRoutes } from "./routes/missions.js";
import { rigCmuxRoutes } from "./routes/rig-cmux.js";
import { CmuxLayoutService } from "./domain/cmux-layout-service.js";
import { getNodeInventory } from "./domain/node-inventory.js";
import { filesRoutes } from "./routes/files.js";
import { progressRoutes } from "./routes/progress.js";
import { steeringRoutes } from "./routes/steering.js";
@@ -342,6 +345,15 @@ export function createApp(deps: AppDeps): Hono {
c.set("nodeLauncher" as never, deps.nodeLauncher);
c.set("tmuxAdapter" as never, deps.tmuxAdapter);
c.set("cmuxAdapter" as never, deps.cmuxAdapter);
// Slice 24 — per-rig CMUX workspace launcher wiring.
c.set(
"cmuxLayoutService" as never,
new CmuxLayoutService(deps.cmuxAdapter),
);
c.set(
"nodeInventoryFn" as never,
(rigId: string) => getNodeInventory(deps.rigRepo.db, rigId),
);
c.set("snapshotCapture" as never, deps.snapshotCapture);
c.set("snapshotRepo" as never, deps.snapshotRepo);
c.set("restoreOrchestrator" as never, deps.restoreOrchestrator);
@@ -425,6 +437,8 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/rigs", rigsRoutes);
app.route("/api/rigs/:rigId/sessions", sessionsRoutes);
// Slice 24 — per-rig CMUX workspace launcher.
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
app.route("/api/rigs/:rigId/nodes", nodesRoutes);
app.route("/api/sessions", sessionAdminRoutes);
app.route("/api/adapters", adaptersRoutes);