mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
1 parent
324f9f907c
commit
a18e99728f
2 files changed
+162
No files matched your search
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user