diff --git a/CONTEXT.md b/CONTEXT.md index 4e20ad4b..03385250 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -75,7 +75,7 @@ The user's machines acting as one logical studio: exactly one Canonical Server p _Avoid_: mesh, cluster **Go Standalone**: -The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself permanently. Not an escape hatch: a first-class choice. Sessions made locally stay local. Re-enrollment in a fleet is a separate flow (Enroll, deferred). +The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself. Not an escape hatch: a first-class choice. Sessions made locally stay local. The `canonical` coordinates are preserved in `fleet.json` so the panel can offer "Reconnect Fleet" (one-click rejoin) rather than forcing a full re-setup. Only "Dismantle Fleet" removes the coordinates entirely. _Avoid_: local fallback, offline mode, degraded mode **Fleet token**: diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 79d4b961..524014db 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -104,6 +104,19 @@ const targets = [ minify: false, logLevel: "info", }, + // Fleet Panel webview bundle (activity bar sidebar — #350) + { + entryPoints: ["src/fleet_webview.ts"], + bundle: true, + platform: "browser", + target: "es2022", + format: "iife", + outfile: "dist/fleet_webview.js", + sourcemap: true, + minify: false, + logLevel: "info", + loader: { ".svg": "text" }, + }, ]; if (watch) { diff --git a/packages/extension/media/ui/components/fleet_profiles_view.ts b/packages/extension/media/ui/components/fleet_profiles_view.ts new file mode 100644 index 00000000..c55aec3a --- /dev/null +++ b/packages/extension/media/ui/components/fleet_profiles_view.ts @@ -0,0 +1,236 @@ +// Fleet Profiles view component — renders the profiles list in the Fleet Panel +// webview. Each row shows name + model/variant + play button + overflow menu. +// Inline form for create/edit. +// +// Part of #356 (Fleet Panel: Fleet Profiles CRUD). + +import { defineStyle } from "../style"; +import { button } from "../atoms/button"; +import type { FleetWebviewMessage } from "../../../src/fleet_panel"; + +defineStyle( + "fleet-profiles", + ` + .profiles-list { display: flex; flex-direction: column; gap: var(--space-xs); } + .profile-row { display: flex; align-items: center; gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); background: var(--bg-box); } + .profile-row .pr-name { font-weight: 600; flex: 1; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .profile-row .pr-model { color: var(--color-dim); font-size: var(--text-small); + flex: 0 0 auto; max-width: 120px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + .profile-row .pr-play { flex: 0 0 auto; cursor: pointer; background: none; + border: none; color: var(--color-ok); font-size: 14px; + padding: 2px 4px; } + .profile-row .pr-play:hover { opacity: 0.8; } + .profile-row .pr-menu { flex: 0 0 auto; cursor: pointer; background: none; + border: none; color: var(--color-dim); font-size: 14px; + padding: 2px 4px; position: relative; } + .profile-row .pr-menu:hover { color: var(--vscode-foreground); } + .profile-menu { position: absolute; right: 0; top: 100%; background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); z-index: 100; min-width: 100px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } + .profile-menu-item { padding: var(--space-xs) var(--space-sm); cursor: pointer; + font-size: var(--text-small); white-space: nowrap; } + .profile-menu-item:hover { background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); } + .profile-form { display: flex; flex-direction: column; gap: var(--space-xs); + padding: var(--space-sm); border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); background: var(--bg-box); } + .profile-form input, .profile-form select { font-family: var(--text-font); + font-size: var(--text-small); padding: var(--space-xs) var(--space-sm); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); background: var(--vscode-input-background, transparent); + color: var(--vscode-foreground); } + .profile-form label { font-size: var(--text-small); color: var(--color-dim); } + .profile-form .form-row { display: flex; flex-direction: column; gap: 2px; } + .profile-form .form-actions { display: flex; gap: var(--space-xs); margin-top: var(--space-xs); } +`, +); + +export interface ProfileSummary { + slug: string; + name: string; + model: string; + variant: string; +} + +export interface ProfilesView { + el: HTMLElement; + update(profiles: ProfileSummary[]): void; +} + +export function createProfilesView(post: (msg: FleetWebviewMessage) => void): ProfilesView { + const el = document.createElement("div"); + const list = document.createElement("div"); + list.className = "profiles-list"; + el.appendChild(list); + + // "+" button at the top + const addBtn = button("+", () => showForm(null)); + addBtn.el.title = "Create new profile"; + el.insertBefore(addBtn.el, list); + + let formEl: HTMLElement | null = null; + let currentMenu: HTMLElement | null = null; + + function dismissMenu(): void { + if (currentMenu) { + currentMenu.remove(); + currentMenu = null; + } + } + + document.addEventListener("click", () => dismissMenu()); + + function showForm(editSlug: string | null, prefill?: ProfileSummary): void { + hideForm(); + formEl = document.createElement("div"); + formEl.className = "profile-form"; + + const fields = [ + { key: "name", label: "Name", value: prefill?.name ?? "" }, + { key: "model", label: "Model", value: prefill?.model ?? "anthropic.claude-opus-4-6-v1" }, + { key: "variant", label: "Variant", value: prefill?.variant ?? "" }, + { key: "base", label: "Base", value: "pulse-designer" }, + { key: "task_type", label: "Task Type", value: "interactive" }, + { key: "skills", label: "Skills (comma-separated)", value: "transmon, atoms, bosonic" }, + ]; + + const inputs: Record = {}; + for (const f of fields) { + const row = document.createElement("div"); + row.className = "form-row"; + const label = document.createElement("label"); + label.textContent = f.label; + const input = document.createElement("input"); + input.type = "text"; + input.value = f.value; + input.placeholder = f.label; + inputs[f.key] = input; + row.appendChild(label); + row.appendChild(input); + formEl.appendChild(row); + } + + const actions = document.createElement("div"); + actions.className = "form-actions"; + + const saveBtn = button(editSlug ? "Save" : "Create", () => { + const payload: Record = { + name: inputs.name.value, + model: inputs.model.value, + variant: inputs.variant.value, + base: inputs.base.value, + task_type: inputs.task_type.value, + skills: inputs.skills.value.split(",").map((s) => s.trim()).filter(Boolean), + }; + if (editSlug) { + post({ type: "action", action: "editProfile", payload: { slug: editSlug, ...payload } }); + } else { + post({ type: "action", action: "createProfile", payload }); + } + hideForm(); + }); + actions.appendChild(saveBtn.el); + + const cancelBtn = button("Cancel", () => hideForm()); + actions.appendChild(cancelBtn.el); + formEl.appendChild(actions); + + el.insertBefore(formEl, list); + } + + function hideForm(): void { + if (formEl) { + formEl.remove(); + formEl = null; + } + } + + function update(profiles: ProfileSummary[]): void { + list.innerHTML = ""; + if (profiles.length === 0) { + const hint = document.createElement("div"); + hint.style.color = "var(--color-dim)"; + hint.style.fontStyle = "italic"; + hint.textContent = "No profiles yet"; + list.appendChild(hint); + return; + } + + for (const p of profiles) { + const row = document.createElement("div"); + row.className = "profile-row"; + + const name = document.createElement("span"); + name.className = "pr-name"; + name.textContent = p.name; + row.appendChild(name); + + const model = document.createElement("span"); + model.className = "pr-model"; + model.textContent = p.variant ? `${p.model} (${p.variant})` : p.model; + row.appendChild(model); + + // Play button + const play = document.createElement("button"); + play.className = "pr-play"; + play.textContent = "\u25B6"; + play.title = "Launch session from this profile"; + play.addEventListener("click", (e) => { + e.stopPropagation(); + post({ type: "action", action: "launch", payload: { slug: p.slug } }); + }); + row.appendChild(play); + + // Overflow menu button + const menuBtn = document.createElement("button"); + menuBtn.className = "pr-menu"; + menuBtn.textContent = "\u22EE"; + menuBtn.addEventListener("click", (e) => { + e.stopPropagation(); + dismissMenu(); + const menu = document.createElement("div"); + menu.className = "profile-menu"; + + const editItem = document.createElement("div"); + editItem.className = "profile-menu-item"; + editItem.textContent = "Edit"; + editItem.addEventListener("click", () => { + dismissMenu(); + showForm(p.slug, p); + }); + menu.appendChild(editItem); + + const dupItem = document.createElement("div"); + dupItem.className = "profile-menu-item"; + dupItem.textContent = "Duplicate"; + dupItem.addEventListener("click", () => { + dismissMenu(); + post({ type: "action", action: "duplicateProfile", payload: { slug: p.slug } }); + }); + menu.appendChild(dupItem); + + const delItem = document.createElement("div"); + delItem.className = "profile-menu-item"; + delItem.textContent = "Delete"; + delItem.addEventListener("click", () => { + dismissMenu(); + post({ type: "action", action: "deleteProfile", payload: { slug: p.slug } }); + }); + menu.appendChild(delItem); + + currentMenu = menu; + menuBtn.appendChild(menu); + }); + row.appendChild(menuBtn); + + list.appendChild(row); + } + } + + return { el, update }; +} diff --git a/packages/extension/media/ui/components/fleet_topology.ts b/packages/extension/media/ui/components/fleet_topology.ts new file mode 100644 index 00000000..4ceb38bb --- /dev/null +++ b/packages/extension/media/ui/components/fleet_topology.ts @@ -0,0 +1,250 @@ +// Fleet topology graph — SVG star-topology renderer with clickable nodes and +// connection lines. Solid = connected, dotted = configured-but-offline. Node +// badges: healthy (green), degraded (yellow), unreachable (red). +// +// Part of #353 (Fleet Panel: SVG topology graph with clickable nodes). + +import { defineStyle } from "../style"; +import type { FleetNode, FleetEdge } from "../../../src/fleet_panel"; + +defineStyle( + "fleet-topology", + ` + .fleet-topology { position: relative; width: 100%; } + .fleet-topology svg { width: 100%; height: 100%; } + .fleet-topology .node { cursor: pointer; } + .fleet-topology .node circle { stroke-width: 2; fill: var(--bg-box, #1e1e1e); } + .fleet-topology .node circle.healthy { stroke: var(--color-ok); } + .fleet-topology .node circle.degraded { stroke: var(--color-run); } + .fleet-topology .node circle.unreachable { stroke: var(--color-fail); } + .fleet-topology .node text { fill: var(--vscode-foreground); font-size: 10px; + text-anchor: middle; dominant-baseline: central; + pointer-events: none; } + .fleet-topology .node .node-label { font-size: 9px; fill: var(--color-dim); + dominant-baseline: hanging; } + .fleet-topology .node .this-machine { font-size: 8px; fill: var(--color-accent-ink); + font-weight: 600; } + .fleet-topology .edge-line { stroke: var(--border-color); stroke-width: 1.5; } + .fleet-topology .edge-line.connected { stroke-dasharray: none; } + .fleet-topology .edge-line.disconnected { stroke-dasharray: 4 3; opacity: 0.6; } + .fleet-topology .standalone-label { fill: var(--color-dim); font-size: 11px; + font-style: italic; text-anchor: middle; } + .fleet-popover { position: absolute; background: var(--bg-box); + border: var(--border-width) solid var(--border-color); + border-radius: var(--border-radius); padding: var(--space-sm) var(--space-md); + font-size: var(--text-small); min-width: 140px; z-index: 100; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } + .fleet-popover .pop-title { font-weight: 600; margin-bottom: var(--space-xs); } + .fleet-popover .pop-row { color: var(--color-dim); margin-bottom: 2px; } + .fleet-popover .pop-row .pop-val { color: var(--vscode-foreground); } +`, +); + +export interface TopologyGraph { + el: HTMLElement; + update(nodes: FleetNode[], edges: FleetEdge[]): void; +} + +/** Compute node positions for a star topology: server at center, clients around it. */ +export function computeNodePositions( + nodes: FleetNode[], + width: number, + height: number, +): Map { + const positions = new Map(); + const cx = width / 2; + const cy = height / 2; + const radius = Math.min(width, height) * 0.35; + + const server = nodes.find((n) => n.role === "server"); + const clients = nodes.filter((n) => n.role === "client"); + + if (server) { + positions.set(server.id, { x: cx, y: cy }); + } + + clients.forEach((client, i) => { + const angle = (2 * Math.PI * i) / Math.max(clients.length, 1) - Math.PI / 2; + positions.set(client.id, { + x: cx + radius * Math.cos(angle), + y: cy + radius * Math.sin(angle), + }); + }); + + return positions; +} + +/** Build the popover content for a node. */ +export function buildPopoverContent(node: FleetNode): HTMLElement { + const el = document.createElement("div"); + el.className = "fleet-popover"; + + const title = document.createElement("div"); + title.className = "pop-title"; + title.textContent = node.hostname; + el.appendChild(title); + + const roleRow = document.createElement("div"); + roleRow.className = "pop-row"; + roleRow.innerHTML = `Role: ${node.role}`; + el.appendChild(roleRow); + + const healthRow = document.createElement("div"); + healthRow.className = "pop-row"; + healthRow.innerHTML = `Health: ${node.healthy ? "healthy" : "unreachable"}`; + el.appendChild(healthRow); + + if (node.lastSeen) { + const lastSeenRow = document.createElement("div"); + lastSeenRow.className = "pop-row"; + lastSeenRow.innerHTML = `Last seen: ${node.lastSeen}`; + el.appendChild(lastSeenRow); + } + + const sessionsRow = document.createElement("div"); + sessionsRow.className = "pop-row"; + sessionsRow.innerHTML = `Sessions: ${node.sessionCount}`; + el.appendChild(sessionsRow); + + if (node.isLocal) { + const localRow = document.createElement("div"); + localRow.className = "pop-row"; + localRow.innerHTML = `This machine`; + el.appendChild(localRow); + } + + return el; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; +const SVG_WIDTH = 240; +const SVG_HEIGHT = 180; +const NODE_RADIUS = 16; + +export function createTopologyGraph(): TopologyGraph { + const el = document.createElement("div"); + el.className = "fleet-topology"; + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + el.appendChild(svg); + + let popover: HTMLElement | null = null; + + function dismissPopover(): void { + if (popover) { + popover.remove(); + popover = null; + } + } + + function showPopover(node: FleetNode, screenX: number, screenY: number): void { + dismissPopover(); + popover = buildPopoverContent(node); + popover.style.left = `${screenX}px`; + popover.style.top = `${screenY + 10}px`; + el.appendChild(popover); + } + + // Dismiss on click-outside + document.addEventListener("click", (e) => { + if (popover && !popover.contains(e.target as Node)) { + dismissPopover(); + } + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") dismissPopover(); + }); + + function update(nodes: FleetNode[], edges: FleetEdge[]): void { + dismissPopover(); + // Clear SVG + while (svg.firstChild) svg.removeChild(svg.firstChild); + + if (nodes.length === 0) { + // Standalone: single node, label + const standaloneTxt = document.createElementNS(SVG_NS, "text"); + standaloneTxt.classList.add("standalone-label"); + standaloneTxt.setAttribute("x", String(SVG_WIDTH / 2)); + standaloneTxt.setAttribute("y", String(SVG_HEIGHT / 2)); + standaloneTxt.textContent = "Standalone \u2014 all local"; + svg.appendChild(standaloneTxt); + return; + } + + const positions = computeNodePositions(nodes, SVG_WIDTH, SVG_HEIGHT); + + // Draw edges first (behind nodes) + for (const edge of edges) { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) continue; + + const line = document.createElementNS(SVG_NS, "line"); + line.classList.add("edge-line", edge.connected ? "connected" : "disconnected"); + line.setAttribute("x1", String(from.x)); + line.setAttribute("y1", String(from.y)); + line.setAttribute("x2", String(to.x)); + line.setAttribute("y2", String(to.y)); + svg.appendChild(line); + } + + // Draw nodes + for (const node of nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const g = document.createElementNS(SVG_NS, "g"); + g.classList.add("node"); + g.setAttribute("data-node-id", node.id); + + // Circle with health badge color + const circle = document.createElementNS(SVG_NS, "circle"); + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + circle.setAttribute("r", String(NODE_RADIUS)); + circle.classList.add(node.healthy ? "healthy" : "unreachable"); + g.appendChild(circle); + + // Role icon text (S for server, C for client) + const roleText = document.createElementNS(SVG_NS, "text"); + roleText.setAttribute("x", String(pos.x)); + roleText.setAttribute("y", String(pos.y)); + roleText.textContent = node.role === "server" ? "S" : "C"; + g.appendChild(roleText); + + // Hostname label below + const label = document.createElementNS(SVG_NS, "text"); + label.classList.add("node-label"); + label.setAttribute("x", String(pos.x)); + label.setAttribute("y", String(pos.y + NODE_RADIUS + 8)); + label.textContent = node.hostname.length > 12 ? node.hostname.slice(0, 11) + "\u2026" : node.hostname; + g.appendChild(label); + + // "This machine" marker + if (node.isLocal) { + const marker = document.createElementNS(SVG_NS, "text"); + marker.classList.add("this-machine"); + marker.setAttribute("x", String(pos.x)); + marker.setAttribute("y", String(pos.y - NODE_RADIUS - 4)); + marker.textContent = "\u25CF you"; + g.appendChild(marker); + } + + // Click handler for popover + g.addEventListener("click", (e) => { + e.stopPropagation(); + const svgRect = svg.getBoundingClientRect(); + const scaleX = svgRect.width / SVG_WIDTH; + const relX = pos.x * scaleX; + const relY = pos.y * (svgRect.height / SVG_HEIGHT); + showPopover(node, relX, relY); + }); + + svg.appendChild(g); + } + } + + return { el, update }; +} diff --git a/packages/extension/media/ui/views/fleet.ts b/packages/extension/media/ui/views/fleet.ts new file mode 100644 index 00000000..3cb488ed --- /dev/null +++ b/packages/extension/media/ui/views/fleet.ts @@ -0,0 +1,191 @@ +// Fleet Panel view — the TS-composed DOM for the Fleet Panel webview. +// Renders topology, profiles, and stats sections. Initially shows the +// standalone empty state with section headers. +// +// Follows the Device Inspector view pattern (media/ui/views/device_inspector.ts). + +import { defineStyle } from "../style"; +import { button } from "../atoms/button"; +import { createTopologyGraph } from "../components/fleet_topology"; +import { createProfilesView } from "../components/fleet_profiles_view"; +import type { FleetHostMessage, FleetWebviewMessage } from "../../../src/fleet_panel"; + +defineStyle( + "fleet-view", + ` + body { margin: 0; min-height: 100vh; font-family: var(--text-font); + font-size: var(--text-body); color: var(--vscode-foreground); + padding: var(--space-lg); } + .fleet-section { margin-bottom: 24px; } + .fleet-section + .fleet-section { padding-top: 4px; } + .section-label { font-size: var(--text-label); text-transform: uppercase; + letter-spacing: 0.6px; font-weight: 600; color: var(--color-dim); + margin-bottom: var(--space-xs); } + .standalone-hint { color: var(--color-dim); font-style: italic; + margin: var(--space-md) 0; } + .fleet-role-badge { font-size: var(--text-small); font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + padding: var(--space-xs) var(--space-md); + border-radius: var(--border-radius-round); + border: var(--border-width) solid currentColor; + display: inline-flex; align-items: center; + gap: var(--space-sm); margin-bottom: 16px; } + .fleet-root > .btn { display: block; margin-bottom: var(--space-md); } + .fleet-root > .btn.create-fleet { border-color: var(--color-accent-ink); + box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent-ink) 40%, transparent); + margin-bottom: 24px; } + .fleet-role-badge.standalone { color: var(--color-dim); } + .fleet-role-badge.server { color: var(--color-ok); } + .fleet-role-badge.client { color: var(--color-run); } + .fleet-compat-banner { font-size: var(--text-small); padding: var(--space-sm) var(--space-md); + border-radius: var(--border-radius); margin-bottom: 12px; + border: var(--border-width) solid; display: none; } + .fleet-compat-banner.degraded { color: var(--color-run); border-color: var(--color-run); + background: color-mix(in srgb, var(--color-run) 8%, transparent); } + .fleet-compat-banner.incompatible { color: var(--color-fail); border-color: var(--color-fail); + background: color-mix(in srgb, var(--color-fail) 8%, transparent); } + .fleet-compat-banner .compat-action { font-weight: 600; cursor: pointer; + text-decoration: underline; margin-left: var(--space-sm); } +`, +); + +export interface FleetView { + el: HTMLElement; + onMessage(msg: unknown): void; +} + +export function createFleetView(post: (msg: FleetWebviewMessage) => void): FleetView { + const el = document.createElement("div"); + el.className = "fleet-root"; + + // ── Role badge ─────────────────────────────────────────────────────────── + const roleBadge = document.createElement("span"); + roleBadge.className = "fleet-role-badge standalone"; + roleBadge.textContent = "Standalone"; + el.appendChild(roleBadge); + + // ── Compatibility banner (shown when degraded/incompatible) ────────────── + const compatBanner = document.createElement("div"); + compatBanner.className = "fleet-compat-banner"; + el.appendChild(compatBanner); + + // ── Create Fleet button (right under the badge in standalone) ──────────── + const createBtn = button("Create Fleet", () => { + post({ type: "action", action: "createFleet" }); + }); + createBtn.el.classList.add("create-fleet"); + createBtn.enable(true); + el.appendChild(createBtn.el); + + // ── Topology section ───────────────────────────────────────────────────── + const topoSection = document.createElement("div"); + topoSection.className = "fleet-section"; + const topoLabel = document.createElement("div"); + topoLabel.className = "section-label"; + topoLabel.textContent = "Topology"; + topoSection.appendChild(topoLabel); + + const topoGraph = createTopologyGraph(); + topoSection.appendChild(topoGraph.el); + // In standalone mode, hide the graph entirely (no empty SVG taking space) + topoGraph.el.style.display = "none"; + topoSection.style.display = "none"; + el.appendChild(topoSection); + + // ── Profiles section ───────────────────────────────────────────────────── + const profilesSection = document.createElement("div"); + profilesSection.className = "fleet-section"; + const profilesLabel = document.createElement("div"); + profilesLabel.className = "section-label"; + profilesLabel.textContent = "Profiles"; + profilesSection.appendChild(profilesLabel); + + const profilesView = createProfilesView(post); + profilesSection.appendChild(profilesView.el); + el.appendChild(profilesSection); + + // ── Stats section ──────────────────────────────────────────────────────── + const statsSection = document.createElement("div"); + statsSection.className = "fleet-section"; + const statsLabel = document.createElement("div"); + statsLabel.className = "section-label"; + statsLabel.textContent = "Stats"; + statsSection.appendChild(statsLabel); + + const statsContent = document.createElement("div"); + statsContent.className = "standalone-hint"; + statsContent.textContent = "No active sessions"; + statsSection.appendChild(statsContent); + el.appendChild(statsSection); + + // ── Message handler ────────────────────────────────────────────────────── + function onMessage(msg: unknown): void { + const m = msg as FleetHostMessage; + if (!m || typeof m !== "object" || !("type" in m)) return; + + switch (m.type) { + case "role": + roleBadge.className = `fleet-role-badge ${m.role}`; + roleBadge.textContent = + m.role === "standalone" ? "Standalone" : m.role === "server" ? "Server" : "Client"; + if (m.role === "standalone") { + topoGraph.el.style.display = "none"; + topoSection.style.display = "none"; + createBtn.enable(true); + createBtn.el.style.display = ""; + if (m.hasCanonical) { + createBtn.el.textContent = "Reconnect Fleet"; + createBtn.el.onclick = () => post({ type: "action", action: "reconnectFleet" }); + } else { + createBtn.el.textContent = "Create Fleet"; + createBtn.el.onclick = () => post({ type: "action", action: "createFleet" }); + } + } else { + topoGraph.el.style.display = ""; + topoSection.style.display = ""; + topoGraph.update([], []); + createBtn.enable(false); + createBtn.el.style.display = "none"; + } + break; + + case "topology": + topoGraph.update(m.nodes, m.edges); + break; + + case "profiles": + profilesView.update(m.profiles); + break; + + case "stats": + if (m.stats.active === 0) { + statsContent.textContent = "No active sessions"; + } else { + statsContent.textContent = `${m.stats.active} active (${m.stats.running} running, ${m.stats.blocked} blocked) \u00B7 ${m.stats.tokensToday} tokens today`; + } + break; + + case "compat": + if (m.state === "compatible") { + compatBanner.style.display = "none"; + } else { + compatBanner.className = `fleet-compat-banner ${m.state}`; + compatBanner.style.display = "block"; + compatBanner.innerHTML = m.state === "incompatible" + ? `${m.message} Go Standalone` + : m.message; + + // Wire up Go Standalone action + const action = compatBanner.querySelector(".compat-action"); + if (action) { + action.addEventListener("click", () => { + post({ type: "action", action: "goStandalone" }); + }); + } + } + break; + } + } + + return { el, onMessage }; +} diff --git a/packages/extension/package.json b/packages/extension/package.json index 033b9bcf..89b8bb50 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -41,6 +41,7 @@ "onCommand:amicode.openInspector", "onView:amicode.runInspector", "onView:amicode.deviceInspector", + "onView:amicode.fleet", "onView:amicode.armonia", "onStartupFinished" ], @@ -67,6 +68,11 @@ }, "views": { "amicode": [ + { + "id": "amicode.fleet", + "name": "Fleet", + "type": "webview" + }, { "id": "amicode.armonia", "name": "Armonia", diff --git a/packages/extension/skills/create-a-fleet/SKILL.md b/packages/extension/skills/create-a-fleet/SKILL.md new file mode 100644 index 00000000..dd45a63f --- /dev/null +++ b/packages/extension/skills/create-a-fleet/SKILL.md @@ -0,0 +1,590 @@ +--- +name: create-a-fleet +description: "Fleet lifecycle management — create, add machines, remove, dismantle, reconfigure. Use when the user clicks Create Fleet or asks to set up/manage a fleet." +agents: [researcher, experimenter] +surface: public +--- + +# Fleet Management + +Manage the Amicode fleet lifecycle — create a fleet, add machines, remove machines, +dismantle, reconfigure, and check sync status. The fleet is a set of machines sharing +one canonical opencode server (the "host"). Clients connect over SSH tunnels and auto-sync +their extension + binary state from the host. + +**Architecture:** Host is ground truth, clients are disposable mirrors, standalone is +the offline fallback. Sync is bidirectional: fleet mode = host→client auto-sync; +standalone dev work pushes back to host on reconnect via direct SSH git push (no GitHub +intermediary). + +## When to use + +Use this skill when: +- The user clicks "Create Fleet" in the fleet panel +- The user asks to set up a fleet, add/remove a machine, dismantle, or reconfigure +- The user asks about fleet status or sync health + +## Prerequisites + +- SSH key-based auth is configured to the target host (you validate, don't set up keys) +- The user tells you the SSH target (`user@host` or an SSH alias) +- You have bash tool access to run `ssh target "command"` from the local machine + +## Operations + +### `/fleet create` — Full Setup Flow + +> **⚠️ SESSION SAFETY — ordering invariant.** Writing `fleet.json` with +> `"role": "client"` on the local machine is what triggers VS Code to switch +> from the local standalone server to the remote fleet server. If the tunnel +> or the server isn't ready when this happens, the session drops mid-setup. +> Therefore: **ALL server-side work, the SSH tunnel, and end-to-end +> verification MUST complete before the local fleet.json is written.** The +> local fleet.json write is Step 17 — the absolute last operation. Never +> reorder it earlier. + +Two modes: **Development clone** (push local repos, build from source) and **Release binary** +(binary already installed on remote). + +#### Step 1: Get the SSH target + +Ask the user for the SSH alias or `user@host` for the remote server. +Use the `question` tool with `kind: "text"` and `options: []` (the schema +requires the `options` key even for free-form text inputs). + +#### Step 2: Validate SSH connectivity + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=10 "echo ok" +``` + +If this fails, the user's SSH keys aren't configured. Tell them and stop. + +#### Step 3: Choose binary mode + +Ask: **Development clone** (for amicode developers — pushes repos, builds from source) +or **Release binary** (binary pre-installed on the remote). + +#### Step 4: Pre-flight checks + +```bash +# Check if port 4096 is available +ssh 'lsof -i :4096 -sTCP:LISTEN 2>/dev/null | grep -q LISTEN && echo taken || echo free' +``` + +If the port is taken, ask the user to choose a different port or stop the existing service. + +#### Step 5 (dev-clone only): Check and install dev tools + +Check each tool: +```bash +ssh "git --version" +ssh "node --version" +ssh "pnpm --version" +ssh "bun --version" +``` + +If any are missing, **offer to install them**: +```bash +# Install bun +ssh 'curl -fsSL https://bun.sh/install | bash' + +# Install pnpm (requires node) +ssh 'npm install -g pnpm' + +# Install node via nvm (if node missing) +ssh 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash && source ~/.nvm/nvm.sh && nvm install --lts' +``` + +Re-check after installation. If still missing, report and stop. + +#### Step 6 (dev-clone only): Detect local repos + +Walk up from the extension path to find the amicode repo root: +```bash +# On the LOCAL machine: +# amicode repo: walk up from the running extension to find .git +# opencode repo: walk up from the configured binary path to find .git +# Fallback: ~/harmoniqs/amicode and ~/harmoniqs/opencode +``` + +#### Step 7 (dev-clone only): Push repos to host + +```bash +# Init bare-ish repos on host (accept pushes to checked-out branch) +ssh 'mkdir -p ~/harmoniqs/opencode ~/harmoniqs/amicode && \ + cd ~/harmoniqs/opencode && [ ! -d .git ] && git init && git config receive.denyCurrentBranch updateInstead; \ + cd ~/harmoniqs/amicode && [ ! -d .git ] && git init && git config receive.denyCurrentBranch updateInstead' + +# Push from local (use 600s timeout — initial push of a 530MB repo takes time) +cd +git remote remove fleet-host 2>/dev/null || true +git remote add fleet-host :~/harmoniqs/opencode +git push fleet-host : --force + +cd +git remote remove fleet-host 2>/dev/null || true +git remote add fleet-host :~/harmoniqs/amicode +git push fleet-host : --force + +# Checkout branches on host +ssh 'cd ~/harmoniqs/opencode && git checkout && cd ~/harmoniqs/amicode && git checkout ' +``` + +**Timeout handling:** If push times out, retry. The initial push of a large repo can take +5-10 minutes. Report progress: "Pushing opencode (530 MB) — this can take several minutes +on the first push." + +#### Step 8: Copy session databases to server + +Copy local opencode session databases to the server **before** the build. This ensures +sessions are in place before the binary or service starts (which would otherwise create +empty DBs). The `local_rebuild_amicode.sh` script's backup/restore logic then protects +them on subsequent rebuilds. + +```bash +# Check if local session databases exist +ls ~/.local/share/opencode/opencode*.db 2>/dev/null + +# Ensure target directory exists +ssh 'mkdir -p ~/.local/share/opencode' + +# Copy databases (clean copy — server has no sessions on first setup) +scp ~/.local/share/opencode/opencode*.db :~/.local/share/opencode/ +# Also copy WAL/SHM sidecars if present +scp ~/.local/share/opencode/opencode*.db-wal :~/.local/share/opencode/ 2>/dev/null || true +scp ~/.local/share/opencode/opencode*.db-shm :~/.local/share/opencode/ 2>/dev/null || true +``` + +If the server already has sessions (e.g. re-creating a fleet), copy with a `.local-merge.db` +suffix and attempt a sqlite3 merge on the server instead of overwriting. + +Skip this step if no local session databases exist (first install). + +#### Step 9 (dev-clone only): Build on host + +```bash +# Build opencode binary +ssh 'cd ~/harmoniqs/opencode/packages/opencode && bun install && bun run script/build.ts --single --skip-install' + +# Build amicode extension +ssh 'cd ~/harmoniqs/amicode && pnpm install && cd packages/extension && pnpm run build' + +# Codesign (macOS only) +ssh 'codesign --sign - --force ~/harmoniqs/opencode/packages/opencode/dist/opencode-darwin-arm64/bin/opencode 2>/dev/null || true' +``` + +**Build failure handling:** If a build fails, read the error output. Common issues: +- Missing native deps: suggest `brew install` or `apt install` +- Node version too old: suggest nvm upgrade +- pnpm lockfile mismatch: suggest `pnpm install --no-frozen-lockfile` + +#### Step 10: Create fleet directory and write fleet.json on host + +```bash +ssh 'mkdir -p ~/.amico/ops/fleet' + +# Write fleet.json (atomic: tmp + mv) +ssh 'cat > ~/.amico/ops/fleet/fleet.json.tmp << '\''EOF'\'' +{ + "role": "server", + "canonical": { + "host": "", + "port": 4096 + } +} +EOF +mv ~/.amico/ops/fleet/fleet.json.tmp ~/.amico/ops/fleet/fleet.json' +``` + +#### Step 11: Generate and store fleet token + +```bash +# Generate a 32-byte hex token +TOKEN=$(openssl rand -hex 32) + +# Store on host +ssh "printf '%s' '${TOKEN}' > ~/.amico/ops/fleet/fleet_token.tmp && chmod 600 ~/.amico/ops/fleet/fleet_token.tmp && mv ~/.amico/ops/fleet/fleet_token.tmp ~/.amico/ops/fleet/fleet_token" +``` + +#### Step 12: Install server service + +**macOS (launchd):** +```bash +ssh 'mkdir -p ~/Library/LaunchAgents && cat > ~/Library/LaunchAgents/co.harmoniqs.amico-server.plist << '\''EOF'\'' + + + + + Label + co.harmoniqs.amico-server + ProgramArguments + + BINARY_PATH + serve + --port + 4096 + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/amico-server.out.log + StandardErrorPath + /tmp/amico-server.err.log + + +EOF +launchctl load ~/Library/LaunchAgents/co.harmoniqs.amico-server.plist 2>/dev/null +launchctl start co.harmoniqs.amico-server' +``` + +Replace `BINARY_PATH` with: +- Dev-clone mode: `~/harmoniqs/opencode/packages/opencode/dist/opencode-darwin-arm64/bin/opencode` +- Release mode: the path from `which opencode` or `~/.local/bin/opencode` + +**Linux (systemd):** +```bash +ssh 'mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/amico-server.service << '\''EOF'\'' +[Unit] +Description=Amico Fleet Server +After=network.target + +[Service] +ExecStart=BINARY_PATH serve --port 4096 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +EOF +systemctl --user daemon-reload +systemctl --user enable --now amico-server.service' +``` + +#### Step 13: Write server version file + +```bash +ssh 'VERSION=$(node -e "console.log(JSON.parse(require(\"fs\").readFileSync(\"$HOME/harmoniqs/amicode/packages/extension/package.json\",\"utf8\")).version)" 2>/dev/null || echo "0.0.0") +mkdir -p ~/.amico/ops/fleet +cat > ~/.amico/ops/fleet/server_version.json << VEOF +{ + "version": "$VERSION", + "schema": 1, + "capabilities": ["sessions", "fleet-state", "fleet-action", "profiles", "host-settings", "sweep", "topology"] +} +VEOF' +``` + +#### Step 14: Verify server is responding + +Before touching anything on the local machine, confirm the server is actually up and +serving. If this fails, do NOT proceed to the client steps — debug the server first. + +```bash +# Server service running? +ssh 'launchctl list | grep co.harmoniqs.amico-server' # macOS +# or: ssh 'systemctl --user is-active amico-server.service' # Linux + +# Server responding on its own loopback? +ssh 'curl -s --max-time 5 http://127.0.0.1:4096/ | head -1' +``` + +If the service is not running or not responding, check logs and fix before continuing: +```bash +ssh 'cat /tmp/amico-server.err.log' +``` + +**GATE: Do not proceed to Step 15 until the server responds successfully.** + +#### Step 15: Install tunnel plist (macOS client) and verify tunnel + +Install the SSH tunnel BEFORE writing the local fleet.json. The tunnel must be +verified working so that when fleet.json flips to `"client"`, the local machine +can immediately reach the server. + +```bash +mkdir -p ~/Library/LaunchAgents +cat > ~/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist << 'EOF' + + + + + Label + co.harmoniqs.amico-tunnel + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + + ProgramArguments + + /usr/bin/ssh + -N + -o + ServerAliveInterval=15 + -o + ServerAliveCountMax=2 + -o + TCPKeepAlive=yes + -L + 127.0.0.1:4096:127.0.0.1:4096 + SSH_ALIAS + + RunAtLoad + + KeepAlive + + StandardErrorPath + /tmp/amico-tunnel.err.log + + +EOF +launchctl unload ~/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist 2>/dev/null +launchctl load ~/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist +``` + +> **Why the PATH:** launchd agents inherit a minimal environment. If the user's SSH +> config has a `ProxyCommand` referencing binaries outside `/usr/bin` (e.g. +> `tailscale nc %h %p` at `/usr/local/bin/tailscale`), the tunnel will silently fail +> without this. Include both `/usr/local/bin` (Intel Homebrew, Tailscale) and +> `/opt/homebrew/bin` (Apple Silicon Homebrew). + +Replace `SSH_ALIAS` with the target, and `4096` with the chosen port. + +**Now verify the tunnel connects end-to-end** (wait a moment for the SSH handshake): +```bash +sleep 2 +curl -s --max-time 5 http://127.0.0.1:4096/ | head -1 +``` + +If this does NOT return a response, the tunnel is broken. Check: +```bash +launchctl list | grep co.harmoniqs.amico-tunnel +cat /tmp/amico-tunnel.err.log +``` + +**GATE: Do not proceed to Step 16 until `curl http://127.0.0.1:4096/` succeeds locally.** + +#### Step 16: Store fleet token locally + +Store the token before writing fleet.json — the token must be in place when VS Code +reads the new role. + +```bash +mkdir -p ~/.amico/ops/fleet +printf '%s' "${TOKEN}" > ~/.amico/ops/fleet/fleet_token.tmp +chmod 600 ~/.amico/ops/fleet/fleet_token.tmp +mv ~/.amico/ops/fleet/fleet_token.tmp ~/.amico/ops/fleet/fleet_token +``` + +#### Step 17: Switch local to client (POINT OF NO RETURN) + +> **⚠️ SESSION SAFETY:** This is the step that causes VS Code to switch from the +> local standalone server to the remote fleet server. Once this file is written, +> the current session MAY disconnect. ALL server-side setup, the tunnel, and the +> token MUST be verified working BEFORE this step executes. Never reorder this +> step earlier in the flow. + +Write the local fleet.json — this is the **last** operation: +```bash +cat > ~/.amico/ops/fleet/fleet.json.tmp << 'EOF' +{ + "role": "client", + "canonical": { + "host": "", + "port": 4096, + "sshAlias": "" + } +} +EOF +mv ~/.amico/ops/fleet/fleet.json.tmp ~/.amico/ops/fleet/fleet.json +``` + +Tell the user: **"Fleet created. Reload VS Code to connect to the host server."** + +--- + +### `/fleet add ` — Add a Machine + +1. Validate SSH connectivity to the new target +2. Run pre-flight (port available, binary present or dev-clone provision) +3. Configure the target as a client (write fleet.json, store token, install tunnel plist) +4. Verify tunnel connects + +--- + +### `/fleet remove ` — Remove a Machine + +1. SSH into the target +2. Revert fleet.json to standalone **but preserve canonical** (so the machine can reconnect later): + ```bash + ssh 'EXISTING=$(cat ~/.amico/ops/fleet/fleet.json 2>/dev/null || echo "{}") && \ + echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); d[\"role\"]=\"standalone\"; json.dump(d,open(\"/tmp/fleet.json.tmp\",\"w\"),indent=2)" && \ + mv /tmp/fleet.json.tmp ~/.amico/ops/fleet/fleet.json' + ``` +3. Unload tunnel/guard services: + ```bash + ssh 'launchctl unload ~/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist 2>/dev/null; true' + ``` + +--- + +### `/fleet dismantle` — Tear Down the Fleet + +1. Read the current fleet config to find the server target +2. Stop the server service: + ```bash + # macOS: + ssh 'launchctl stop co.harmoniqs.amico-server 2>/dev/null; launchctl unload ~/Library/LaunchAgents/co.harmoniqs.amico-server.plist 2>/dev/null; true' + # Linux: + ssh 'systemctl --user stop amico-server.service 2>/dev/null; systemctl --user disable amico-server.service 2>/dev/null; true' + ``` +3. Revert server to standalone (same as remove — preserves canonical) +4. Revert local to standalone, **removing canonical** (dismantle = no fleet to reconnect to): + ```bash + cat > ~/.amico/ops/fleet/fleet.json.tmp << 'EOF' + {"role": "standalone"} + EOF + mv ~/.amico/ops/fleet/fleet.json.tmp ~/.amico/ops/fleet/fleet.json + ``` +5. Unload local tunnel plist + +--- + +### `/fleet reconfigure` — Change Host/Port/SSH Alias + +1. Read current fleet.json +2. Ask what to change (host, port, SSH alias) +3. Update fleet.json (atomic write) +4. If port changed: update tunnel plist, reload +5. If host changed: update tunnel plist, reload; verify connectivity + +--- + +### `/fleet status` — Show Current State + +```bash +# Local role +cat ~/.amico/ops/fleet/fleet.json + +# Server health (if client) +curl -s http://127.0.0.1:4096/ | head -1 + +# Tunnel status (macOS) +launchctl list | grep co.harmoniqs.amico-tunnel + +# Server service status (if server) +ssh 'launchctl list | grep co.harmoniqs.amico-server' +``` + +--- + +## Error Handling + +### Missing tools (install them) +Never just report "tool X not found." Always offer to install: +- **bun**: `curl -fsSL https://bun.sh/install | bash` +- **pnpm**: `npm install -g pnpm` +- **node**: via nvm or system package manager +- **git**: `brew install git` (macOS) or `apt install git` (Linux) + +### Push timeout (retry with progress) +The initial push of a ~530 MB repo can take 5-10 minutes. If it times out: +- Report how far it got (git push shows progress) +- Retry — subsequent pushes are incremental and much faster +- Consider: `git push --force` with a longer timeout + +### Build failure (read error, fix, retry) +- Read the full stderr output +- Common patterns: missing native dependency, wrong Node version, lockfile drift +- Fix the issue on the host (install deps, update node), then retry the build step + +### Service won't start (check logs) +```bash +# macOS: +ssh 'cat /tmp/amico-server.err.log' +ssh 'cat /tmp/amico-server.out.log' + +# Linux: +ssh 'journalctl --user -u amico-server.service --no-pager -n 50' +``` + +--- + +## Fleet.json Schema + +```json +{ + "role": "standalone" | "server" | "client", + "canonical": { + "host": "user@hostname", + "port": 4096, + "sshAlias": "optional-ssh-alias" + } +} +``` + +Location: `~/.amico/ops/fleet/fleet.json` + +**All writes MUST be atomic** — write to `.tmp`, then `mv`: +```bash +cat > ~/.amico/ops/fleet/fleet.json.tmp << 'EOF' + +EOF +mv ~/.amico/ops/fleet/fleet.json.tmp ~/.amico/ops/fleet/fleet.json +``` + +--- + +## Local Repo Detection + +The agent detects repo paths from the running build context: + +- **Amicode repo:** Walk up from the extension install path (the running VS Code extension + lives inside the repo) until finding `.git`. Fallback: `~/harmoniqs/amicode`. +- **Opencode repo:** Walk up from the configured opencode binary path (the `amicode.opencodeBinary` + VS Code setting) until finding `.git`. Fallback: `~/harmoniqs/opencode`. + +To find these in practice: +```bash +# The extension path is available in the chat context — walk up to .git +# The binary path is in VS Code settings: amicode.opencodeBinary + +# Or use the fallbacks directly: +ls ~/harmoniqs/amicode/.git # amicode repo +ls ~/harmoniqs/opencode/.git # opencode repo +``` + +--- + +## Validation Checklist + +Run these to confirm the fleet is healthy: + +1. **SSH reachable**: `ssh -o BatchMode=yes -o ConnectTimeout=10 "echo ok"` +2. **Tools present** (dev-clone): `ssh "git --version && node --version && pnpm --version && bun --version"` +3. **Repos pushed** (dev-clone): `ssh "test -d ~/harmoniqs/opencode/.git && test -d ~/harmoniqs/amicode/.git && echo ok"` +4. **Build succeeds** (dev-clone): Binary exists at expected path +5. **Service running**: `ssh 'launchctl list | grep co.harmoniqs.amico-server'` (macOS) +6. **Server responding**: `ssh 'curl -s http://127.0.0.1:4096/ | head -1'` +7. **Tunnel connects** (client): `curl -s http://127.0.0.1:4096/ | head -1` (from local) + +--- + +## Constraints + +- The agent MUST validate SSH connectivity before any remote operation +- The agent MUST NOT store SSH passwords or private keys +- The agent MUST report each step's outcome before proceeding to the next +- The agent MUST offer to install missing tools (not just report them missing) +- Fleet.json writes MUST be atomic (tmp + rename) +- The canonical server MUST be a system service (launchd/systemd), not a child process +- Sessions MUST survive client disconnect (the service config ensures this) + +## Cross-reference + +For fleet troubleshooting (sync conflicts, tunnel down, drift, SSH failures), +use `/fleet-troubleshooting` instead. It is launched automatically from fleet +notifications when an issue is detected. diff --git a/packages/extension/skills/fleet-troubleshooting/SKILL.md b/packages/extension/skills/fleet-troubleshooting/SKILL.md new file mode 100644 index 00000000..5ccfc3d6 --- /dev/null +++ b/packages/extension/skills/fleet-troubleshooting/SKILL.md @@ -0,0 +1,226 @@ +--- +name: fleet-troubleshooting +description: Diagnose and resolve fleet issues — sync conflicts, tunnel connectivity, fleet drift, SSH failures, and sync errors. Launched automatically from fleet notifications. +agents: [pulse-designer] +surface: public +--- + +# Fleet Troubleshooting + +You are helping the user diagnose and resolve a fleet issue. This skill is +triggered automatically when the fleet detects a problem — the prompt includes +structured diagnostic context under `## Fleet Issue` with the issue kind and +relevant details. + +## Issue Kinds + +### sync-conflict + +**What happened:** The local and host git repositories have diverged — a push +was rejected because both sides have commits the other doesn't. + +**Diagnosis steps:** + +1. Identify the repo (amicode or opencode) from the context +2. Show the divergence: + ```bash + git -C log --oneline .. # local-only commits + git -C log --oneline .. # host-only commits + ``` +3. Check if the local commits are meaningful (real work vs auto-sync artifacts) + +**Resolution strategies (present all, recommend based on context):** + +- **Rebase local onto host** (recommended when local has real work): + ```bash + git -C fetch fleet-host + git -C rebase fleet-host/ + ``` + Then re-run sync: `Cmd+Shift+P → Amicode: Sync with fleet host` + +- **Force-push local to host** (when local is ahead and host changes are stale): + ```bash + git -C push fleet-host --force-with-lease + ``` + Then trigger host rebuild via sync command. + +- **Hard-reset local to host** (when local changes are expendable): + ```bash + git -C fetch fleet-host + git -C reset --hard fleet-host/ + ``` + +**After resolution:** Tell the user to re-run sync (`Cmd+Shift+P → Amicode: Sync with fleet host`) to verify. + +--- + +### tunnel-down + +**What happened:** The SSH tunnel to the fleet host is not responding. The +extension polled `127.0.0.1:` multiple times and got no response. + +**Diagnosis steps:** + +1. Check if the tunnel LaunchAgent is running: + ```bash + launchctl list | grep amico-tunnel + ``` +2. Check if the SSH connection to the host works at all: + ```bash + ssh -o BatchMode=yes -o ConnectTimeout=5 echo ok + ``` +3. Check if the port forward is listening: + ```bash + lsof -i TCP: -sTCP:LISTEN + ``` +4. Check the tunnel log: + ```bash + cat ~/Library/Logs/amico-tunnel.log | tail -30 + ``` + +**Resolution strategies:** + +- **Restart the tunnel** (most common fix): + ```bash + launchctl kickstart -k gui/$(id -u)/co.harmoniqs.amico-tunnel + ``` + +- **Re-establish SSH connectivity** (if SSH itself fails): + - Check `~/.ssh/config` for the host alias + - Verify the host is reachable: `ping ` + - Check SSH key: `ssh-add -l` + - If using Tailscale: `tailscale status` + +- **Port conflict** (if another process holds the port): + ```bash + lsof -i TCP: -sTCP:LISTEN + kill # if it's a stale tunnel + launchctl kickstart -k gui/$(id -u)/co.harmoniqs.amico-tunnel + ``` + +- **Go standalone** (if the host is genuinely unreachable): + ```bash + # Via command palette: Amicode: Go Standalone + ``` + Explain that standalone mode runs everything locally until the host returns. + +--- + +### fleet-drift + +**What happened:** The fleet client's local services are misconfigured — the +LaunchAgent plist, guard script, or extension settings don't match what the +fleet expects. + +**Diagnosis steps:** + +1. Read which checks failed from the context (`failed_checks` field) +2. For each failed check, inspect the specific file: + - **guard**: Check `~/harmoniqs/amicode/packages/extension/fleet-guard.sh` exists and is executable + - **tunnel plist**: Check `~/Library/LaunchAgents/co.harmoniqs.amico-tunnel.plist` has correct port/host + - **binary path**: Check `amicode.opencodeBinary` in VS Code settings matches the fleet binary + - **port mismatch**: Check `amicode.opencodePort` matches the tunnel's local port + +**Resolution strategies:** + +- **Re-run fleet setup** (recommended — fixes everything): + ```bash + # The fleet installer script re-derives all config from fleet.json + bash ~/harmoniqs/amicode/packages/extension/scripts/fleet-client-install.sh + ``` + +- **Fix individual settings** (surgical, when only one check failed): + - Update `amicode.opencodeBinary` in VS Code settings + - Edit the plist to fix port/host, then `launchctl bootout` + `bootstrap` + - Regenerate the guard script + +- **After fixing:** Reload the window (`Cmd+Shift+P → Developer: Reload Window`) + +--- + +### ssh-failure + +**What happened:** An SSH command to the fleet host failed — auth rejected, +host key mismatch, timeout, or other SSH error. + +**Diagnosis steps:** + +1. Read the error from context (`stderr`, `exit_code`) +2. Test SSH manually: + ```bash + ssh -vvv -o BatchMode=yes -o ConnectTimeout=10 echo ok + ``` +3. Common error patterns: + - `Permission denied` → key not loaded or not authorized + - `Host key verification failed` → host key changed (MITM warning or reinstall) + - `Connection timed out` → network issue or host down + - `Connection refused` → SSH daemon not running on host + +**Resolution strategies:** + +- **Key not loaded:** + ```bash + ssh-add ~/.ssh/id_ed25519 # or whichever key + ssh-add -l # verify + ``` + +- **Key not authorized on host:** + ```bash + ssh-copy-id + ``` + +- **Host key changed** (only if you trust the change): + ```bash + ssh-keygen -R + ssh # accept new key + ``` + +- **Host unreachable:** Check network, VPN/Tailscale status, firewall rules. + +--- + +### sync-error + +**What happened:** The sync process failed for a non-conflict reason — a build +failed on the host, rsync/scp failed, or git operations errored. + +**Diagnosis steps:** + +1. Read the error message from context +2. Common patterns: + - `build failed` → compilation error on the host after pulling + - `rsync: connection unexpectedly closed` → SSH interrupted mid-transfer + - `git: not a git repository` → repo path is wrong or deleted + +**Resolution strategies:** + +- **Build failure on host:** SSH in and check: + ```bash + ssh "cd ~/harmoniqs/opencode && git status && npm run build 2>&1 | tail -20" + ``` + Fix the build error, then re-sync. + +- **Transfer failure:** Usually transient — retry: + ```bash + # Cmd+Shift+P → Amicode: Sync with fleet host + ``` + +- **Missing repo:** The host may need re-cloning: + ```bash + ssh "cd ~/harmoniqs && git clone " + ``` + +--- + +## General Principles + +1. **Always show what you're doing** — run diagnostic commands and show their output before proposing fixes. +2. **Prefer the least destructive option** — rebase over force-push, restart over reinstall. +3. **Verify after fixing** — always end with a verification step (re-run sync, check status). +4. **Offer standalone as escape hatch** — if the issue can't be resolved quickly, going standalone lets the user keep working while we debug. +5. **Read fleet.json for topology** — `~/.amico/ops/fleet/fleet.json` has the host target, port, and role. + +## Cross-reference + +For fleet lifecycle operations (create, add, remove, dismantle, reconfigure), +use `/create-a-fleet` instead. diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 186bbd4f..921fe8d9 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -30,6 +30,10 @@ export const BRIDGE_ALLOWED_COMMANDS: ReadonlySet = new Set([ // the fork forwards it here so the editor's Command Palette (where every // Amicode: command lives) opens as users expect. "workbench.action.showCommands", + // #358 fleet actions from the sessions dropdown context menu + "amicode.fleet.steer", + "amicode.fleet.stop", + "amicode.fleet.reTier", ]); /** The bug-session lifecycle sink (amicode#250) — the panels wire the @@ -221,5 +225,17 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // #358 Fleet action from the sessions dropdown context menu (app → extension). + // The app posts fleet-action messages with verb + session_id; we enqueue signal + // files (same single-writer path as CLI verbs). + if (msg.kind === "fleet-action") { + const verb = (msg as unknown as { verb?: unknown }).verb; + const sessionId = (msg as unknown as { session_id?: unknown }).session_id; + if (typeof verb === "string" && typeof sessionId === "string" && sessionId !== "") { + void vscode.commands.executeCommand(`amicode.fleet.bridgeAction`, { verb, session_id: sessionId, params: (msg as unknown as { params?: unknown }).params ?? {} }); + } + return true; + } + return false; } diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index cccf4732..c1acdaba 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -135,6 +135,25 @@ export class ChatPanel { setTimeout(post, 1500); } + /** Post a draft message to the chat. The app will populate the input with this + * text (user can review before sending). Posted twice for reliability (same + * pattern as postComputeConnect). */ + postDraftMessage(message: string): void { + const envelope = { source: "amicode", kind: "draft-message", message }; + try { void this.panel.webview.postMessage(envelope); } catch {} + setTimeout(() => { try { void this.panel.webview.postMessage(envelope); } catch {} }, 1500); + } + + /** Navigate the iframe to a new in-app route. Used to open a new session + * with a pre-filled prompt: navigateToPath("/new-session?prompt=..."). */ + navigateToPath(path: string): void { + const envelope = { source: "amicode" as const, kind: "navigate" as const, path }; + try { void this.panel.webview.postMessage(envelope); } catch {} + } + + /** The tab title of this chat panel. */ + get title(): string { return this.tabTitle; } + /** Lowest free tab label: the lone tab reads "Amicode Chat"; extras take the * smallest unused "Amicode Chat N" (N ≥ 2). Numbers free up on dispose, so a * closed tab's number is reused — existing tabs are never retitled. */ @@ -268,7 +287,16 @@ export class ChatPanel { // Lane 2 — extension → iframe: posted by the extension host // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report")) { + if (d && d.source === "amicode" && d.kind === "navigate") { + // Client-side navigate: post into the iframe so the SPA router picks + // it up without a full page reload (preserves auth state). + var f = document.querySelector("iframe"); + if (f && f.contentWindow && d.path) { + f.contentWindow.postMessage(d, ${origin}); + } + return; + } + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "draft-message")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index bec1aa0f..f5e19ee5 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -55,7 +55,7 @@ import { } from "./substrate/julia_setup"; import { probeCommand, formatHealthReport, type HealthResult } from "./healthcheck"; import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; -import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; +import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, writeFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; import { registerAmicodeTerminal } from "./terminal"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; @@ -69,6 +69,14 @@ import * as os from "node:os"; import { readTomlSafe } from "./run_dir_reader"; import { parse as parseYaml } from "yaml"; import { registerDeviceInspector, getDeviceInspector, revealDeviceInspector } from "./device_inspector"; +import { registerFleetPanel, getFleetPanel } from "./fleet_panel"; +import { registerFleetProfiles } from "./fleet_profile_manager"; + + +import { launchFromProfile, getFleetStats, sweepCrashed } from "./fleet_launch"; +import { readProfile, PROFILES_DIR } from "./fleet_profiles"; +import { parseFleetAction, enqueueFleetSignal, createFleetStateWatcher } from "./fleet_bridge"; +import { syncFromHost, shouldAutoSync } from "./fleet_sync"; import { loadGraph } from "./calibration_graph"; import { parseStateJson } from "./device_registry"; import { buildDeviceStatus, nextActions, capabilityHint, type DriveLine } from "./device_status"; @@ -348,6 +356,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const trees = registerTrees(ctx); registerRunInspector(ctx); registerDeviceInspector(ctx); // Spec A §3 — device dashboard, sibling to the Run Inspector + const fleetPanel = registerFleetPanel(ctx); // #350 — fleet topology, profiles, and stats panel + registerFleetProfiles(ctx, fleetPanel); // #356 — profile CRUD + fs.watch registerCatalogCard(ctx); // #47 dev scaffold — card opens via the save-to-catalog flow ctx.subscriptions.push( // #47 session catalog: record the save (workspaceState + tree), then open @@ -396,6 +406,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); statusBar = new StatusBarManager(); + { + const role = getFleetRole(); + const cfg = role === "client" ? readFleetConfig() : undefined; + const hostInfo = cfg?.canonical ? `${cfg.canonical.host ?? "unknown"}:${cfg.canonical.port ?? 4096}` : undefined; + statusBar.setFleetRole(role, hostInfo); + } ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); // 2. Start the multi-run RunsManager immediately — it tails the append-only @@ -635,16 +651,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } else if (!up && !fleetReady && fleetChecks === 1) { opencodeChannel.appendLine(`[fleet] waiting for tunnel 127.0.0.1:${fleetPort} — canonical unreachable, will retry`); } - // After ~10s (5 checks) still down → offer standalone visibly, not just a log + // After ~10s (5 checks) still down → notify via unified fleet-issue mechanism if (!up && !fleetReady && !fleetNotified && fleetChecks >= 5) { fleetNotified = true; - opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering standalone`); - void vscode.window - .showWarningMessage(`Amicode: fleet tunnel down — canonical unreachable. Go standalone?`, `Go Standalone`, `Show log`) - .then((pick) => { - if (pick === `Go Standalone`) void vscode.commands.executeCommand(`amicode.fleet.goStandalone`); - else if (pick === `Show log`) opencodeChannel.show(); - }); + opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering resolution`); + notifyFleetIssue({ + kind: "tunnel-down", + summary: `Fleet tunnel down — canonical server unreachable at 127.0.0.1:${fleetPort}`, + context: { + host: "127.0.0.1", + port: fleetPort, + checks_failed: fleetChecks, + last_status: "connection refused or timeout", + }, + }); } } catch { if (fleetReady) { @@ -657,13 +677,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } if (!fleetReady && !fleetNotified && fleetChecks >= 5) { fleetNotified = true; - opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering standalone`); - void vscode.window - .showWarningMessage(`Amicode: fleet tunnel down — canonical unreachable. Go standalone?`, `Go Standalone`, `Show log`) - .then((pick) => { - if (pick === `Go Standalone`) void vscode.commands.executeCommand(`amicode.fleet.goStandalone`); - else if (pick === `Show log`) opencodeChannel.show(); - }); + opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering resolution`); + notifyFleetIssue({ + kind: "tunnel-down", + summary: `Fleet tunnel down — canonical server unreachable at 127.0.0.1:${fleetPort}`, + context: { + host: "127.0.0.1", + port: fleetPort, + checks_failed: fleetChecks, + last_status: "fetch threw (network error)", + }, + }); } } }; @@ -1239,21 +1263,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Migrate legacy fallback.json on activation. migrateLegacyFallback(); - const fleetStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); - ctx.subscriptions.push(fleetStatusItem); - const refreshFleetStatus = (): void => { + /** Update the single fleet status-bar item via StatusBarManager. */ + const refreshFleetStatusBar = (): void => { const role = getFleetRole(); - if (role === "client") { - const cfg = readFleetConfig(); - fleetStatusItem.text = "$(cloud) Fleet: client"; - fleetStatusItem.tooltip = `Fleet client → ${cfg?.canonical?.host ?? "unknown"}:${cfg?.canonical?.port ?? 4096}`; - fleetStatusItem.command = "amicode.fleet.goStandalone"; - fleetStatusItem.show(); - } else { - fleetStatusItem.hide(); - } + const cfg = role === "client" ? readFleetConfig() : undefined; + const hostInfo = cfg?.canonical ? `${cfg.canonical.host ?? "unknown"}:${cfg.canonical.port ?? 4096}` : undefined; + statusBar?.setFleetRole(role, hostInfo); }; - refreshFleetStatus(); const runFleetGoStandalone = async (): Promise => { const role = getFleetRole(); @@ -1273,9 +1289,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const prevPort = cfg.get("opencodePort", 0); goStandalone({ previousBinary: prevBinary, previousPort: prevPort }); try { - // Clear the fleet guard override → vendored binary, ephemeral port + // Clear the fleet guard override → vendored binary. Port is NEVER touched + // by fleet enrollment (it's the user's standalone preference), so leave it. await cfg.update("opencodeBinary", "", vscode.ConfigurationTarget.Global); - await cfg.update("opencodePort", 0, vscode.ConfigurationTarget.Global); } catch (e) { opencodeChannel.appendLine(`[fleet] go standalone: settings update failed: ${(e as Error).message}`); } @@ -1290,7 +1306,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // plist may not exist or already unloaded — fine } } - refreshFleetStatus(); + refreshFleetStatusBar(); opencodeChannel.appendLine(`[fleet] Go Standalone — restarting server locally (was binary=${prevBinary || "(vendored)"} port=${prevPort})`); try { await serverManager?.stop(); @@ -1344,6 +1360,326 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions.push(vscode.commands.registerCommand("amicode.fleet.goStandalone", () => void runFleetGoStandalone())); + // Reconnect Fleet — inverse of Go Standalone. Seamlessly switch back to client + // mode without a window reload: stop local server, write client role, load tunnel, + // start polling the canonical server. + const runFleetReconnect = async (): Promise => { + const cfg = readFleetConfig(); + if (!cfg?.canonical?.host) { + void vscode.window.showErrorMessage("Amicode: no fleet configuration found. Use Create Fleet instead."); + return; + } + const fleetPort = cfg.canonical.port ?? 4096; + opencodeChannel.appendLine(`[fleet] reconnecting to canonical at 127.0.0.1:${fleetPort}...`); + + // Write client role (canonical already preserved) + writeFleetConfig({ ...cfg, role: "client" }); + + // Load the tunnel plist if not already active (best-effort, darwin only) + if (process.platform === "darwin") { + try { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "co.harmoniqs.amico-tunnel.plist"); + const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); + execFileSync("launchctl", ["load", plistPath], { timeout: 5000, stdio: "ignore" }); + opencodeChannel.appendLine(`[fleet] loaded tunnel plist`); + } catch { + // Already loaded or doesn't exist — fine + } + } + + // Stop the local server + try { + await serverManager?.stop(); + serverManager = undefined; + statusBar?.setServerReady(false); + opencodeReadyUrl = undefined; + } catch (e) { + opencodeChannel.appendLine(`[fleet] failed to stop local server: ${(e as Error).message}`); + } + + // Clear any existing poll + if (fleetClientPoll) { clearInterval(fleetClientPoll); fleetClientPoll = undefined; } + + // Start polling the tunnel (same logic as activation client path) + let fleetReady = false; + let fleetChecks = 0; + const checkFleet = async () => { + fleetChecks++; + try { + const r = await fetch(`http://127.0.0.1:${fleetPort}/`, { + signal: AbortSignal.timeout(1500), + headers: serverAuthHeaders, + }); + const up = r.ok || (r.status >= 200 && r.status < 400); + if (up && !fleetReady) { + fleetReady = true; + opencodeReadyUrl = new URL(`http://127.0.0.1:${fleetPort}`); + statusBar?.setServerReady(true); + sseClient?.connect(opencodeReadyUrl); + if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + ChatPanel.openOrReveal(ctx, opencodeReadyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + } + opencodeChannel.appendLine(`[fleet] reconnected — tunnel up at ${opencodeReadyUrl}`); + void vscode.window.showInformationMessage("Amicode: Reconnected to fleet."); + } + } catch { + if (fleetChecks === 1) { + opencodeChannel.appendLine(`[fleet] waiting for tunnel 127.0.0.1:${fleetPort}...`); + } + } + }; + fleetClientPoll = setInterval(() => void checkFleet(), 2000); + void checkFleet(); + + refreshFleetStatusBar(); + fleetPanel?.pushRole(); + }; + + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.fleet.reconnectFleet", () => void runFleetReconnect())); + + // Fleet panel focus command — status bar item clicks this to reveal the panel. + // VS Code auto-registers `amicode.fleet.focus` for the webview view, so we use + // a distinct command name that delegates to it. + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.fleetPanel.focus", () => { + void vscode.commands.executeCommand("amicode.fleet.focus"); + })); + + // #363 — Fleet issue notifications: any fleet problem surfaces a VS Code + // notification with a "Resolve with Amico" button that opens a new chat + // session routed to /fleet-troubleshooting with structured diagnostic context. + type FleetIssueKind = "sync-conflict" | "tunnel-down" | "fleet-drift" | "ssh-failure" | "sync-error"; + interface FleetIssue { + kind: FleetIssueKind; + summary: string; + context: Record; + } + + function buildTroubleshootingPrompt(issue: FleetIssue): string { + const lines: string[] = [ + `/fleet-troubleshooting`, + ``, + `## Fleet Issue: ${issue.kind}`, + ``, + issue.summary, + ``, + `### Diagnostic Context`, + ``, + ]; + for (const [key, val] of Object.entries(issue.context)) { + if (val !== undefined) lines.push(`- **${key}**: ${val}`); + } + return lines.join("\n"); + } + + function notifyFleetIssue(issue: FleetIssue): void { + opencodeChannel.appendLine(`[fleet] issue: ${issue.kind} — ${issue.summary}`); + void vscode.window + .showWarningMessage( + `Amicode fleet: ${issue.summary}`, + "Resolve with Amico", + "Show log", + ) + .then((pick) => { + if (pick === "Resolve with Amico") { + const prompt = buildTroubleshootingPrompt(issue); + launchFleetChat(prompt); + } else if (pick === "Show log") { + opencodeChannel.show(); + } + }); + } + + // #363 — Fleet lifecycle commands: launch chat sessions with the fleet skill. + // The agent handles SSH, tool installation, builds, and service configuration + // interactively in the chat — replacing the former sequential QuickPick wizard. + function launchFleetChat(prompt: string): void { + const readyUrl = opencodeReadyUrl; + if (!readyUrl) { + // Server not ready yet — open the main chat (triggers server boot) + // and poll for readyUrl, then navigate once available. + void vscode.commands.executeCommand("amicode.openChat"); + const poll = setInterval(() => { + if (opencodeReadyUrl) { + clearInterval(poll); + const panel = ChatPanel.openOrReveal(ctx, opencodeReadyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + panel.navigateToPath(`/new-session?prompt=${encodeURIComponent(prompt)}&autoSend=1`); + } + }, 500); + // Give up after 30s + setTimeout(() => clearInterval(poll), 30000); + return; + } + const chatPanel = ChatPanel.openOrReveal(ctx, readyUrl, serverAuthToken(serverPassword), opencodeProject.projectDir); + chatPanel.navigateToPath(`/new-session?prompt=${encodeURIComponent(prompt)}&autoSend=1`); + } + + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.fleet.createFleet", () => { + launchFleetChat("/create-a-fleet I want to create a fleet with a remote machine as the server."); + }), + vscode.commands.registerCommand("amicode.fleet.addMachine", () => { + launchFleetChat("/create-a-fleet I want to add a new machine to my fleet."); + }), + vscode.commands.registerCommand("amicode.fleet.removeMachine", (payload?: { target?: string }) => { + const target = payload?.target; + const prompt = target + ? `/create-a-fleet I want to remove ${target} from my fleet.` + : "/create-a-fleet I want to remove a machine from my fleet."; + launchFleetChat(prompt); + }), + vscode.commands.registerCommand("amicode.fleet.dismantle", () => { + launchFleetChat("/create-a-fleet I want to dismantle my fleet and revert all machines to standalone."); + }), + ); + + // #357 — Session launch from profile + aggregate stats + sweep + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.fleet.launch", (payload?: { slug?: string }) => { + const slug = payload?.slug; + if (!slug) return; + const profile = readProfile(path.join(PROFILES_DIR, `${slug}.toml`)); + if (!profile) { + void vscode.window.showErrorMessage(`Profile "${slug}" not found`); + return; + } + const { sessionId } = launchFromProfile(profile); + void vscode.window.showInformationMessage(`Session ${sessionId} spooling from "${profile.name}"`); + // Refresh stats + const panel = getFleetPanel(); + if (panel) panel.postStats(getFleetStats()); + }), + vscode.commands.registerCommand("amicode.fleet.sweep", () => { + const swept = sweepCrashed(); + if (swept.length === 0) { + void vscode.window.showInformationMessage("No orphaned sessions found"); + } else { + void vscode.window.showInformationMessage(`Swept ${swept.length} crashed session${swept.length === 1 ? "" : "s"}`); + } + const panel = getFleetPanel(); + if (panel) panel.postStats(getFleetStats()); + }), + ); + + // Push initial stats to the fleet panel + { + const panel = getFleetPanel(); + if (panel) panel.postStats(getFleetStats()); + } + + // #358 — Fleet bridge: action handler + state watcher + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.fleet.bridgeAction", (payload?: { verb?: string; session_id?: string; params?: Record }) => { + if (!payload?.verb || !payload?.session_id) return; + const action = parseFleetAction({ type: "fleet-action", ...payload }); + if (action) { + enqueueFleetSignal(action); + // Refresh stats after action + const panel = getFleetPanel(); + if (panel) panel.postStats(getFleetStats()); + } + }), + ); + + // Fleet state watcher: debounced 500ms push to any connected panels/bridges + const fleetWatcher = createFleetStateWatcher((_snapshot) => { + // Push stats to the fleet panel on any record change + const panel = getFleetPanel(); + if (panel) panel.postStats(getFleetStats()); + // The snapshot would also be pushed to the app via the chat bridge + // (postToWebview). That wiring depends on the chat panel being open. + }); + ctx.subscriptions.push({ dispose: () => fleetWatcher.dispose() }); + + + + // Fleet auto-sync on activation (if client mode) + const autoSync = shouldAutoSync(); + if (autoSync.should && autoSync.target) { + const syncTarget = autoSync.target; + void (async () => { + const panel = getFleetPanel(); + if (panel) panel.postCompat("compatible", "Syncing..."); + + const result = await syncFromHost(syncTarget, { + onProgress: (step) => { + if (panel) panel.postCompat("compatible", step); + }, + }); + + if (result.conflict) { + // Conflict detected — notify via unified fleet-issue mechanism + if (panel) panel.postCompat("incompatible", `Sync conflict in ${result.conflict.repo} — click to resolve`); + notifyFleetIssue({ + kind: "sync-conflict", + summary: `Sync conflict in ${result.conflict.repo}: local and host have diverged`, + context: { + repo: result.conflict.repo, + conflict_type: result.conflict.type, + local_sha: result.conflict.localSha, + remote_sha: result.conflict.remoteSha, + detail: result.conflict.detail, + }, + }); + } else if (result.synced) { + if (panel) panel.postCompat("compatible", result.buildUpdated ? "Synced — reload window" : "Up to date"); + if (result.buildUpdated) { + const reload = await vscode.window.showInformationMessage( + "Fleet sync complete — extension updated. Reload to pick up changes?", + "Reload Window", + ); + if (reload === "Reload Window") { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + } + } else if (result.error) { + if (panel) panel.postCompat("degraded", `Sync error: ${result.error}`); + notifyFleetIssue({ + kind: "sync-error", + summary: `Sync failed: ${result.error}`, + context: { error: result.error }, + }); + } + })(); + } + + // Manual sync command + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.fleet.sync", async () => { + const { should, target } = shouldAutoSync(); + if (!should || !target) { + void vscode.window.showInformationMessage("Not in fleet client mode — nothing to sync"); + return; + } + const panel = getFleetPanel(); + if (panel) panel.postCompat("compatible", "Syncing..."); + + const result = await syncFromHost(target, { + forceBuild: true, + onProgress: (step) => { if (panel) panel.postCompat("compatible", step); }, + }); + + if (result.conflict) { + if (panel) panel.postCompat("incompatible", `Conflict in ${result.conflict.repo}`); + notifyFleetIssue({ + kind: "sync-conflict", + summary: `Sync conflict in ${result.conflict.repo}: local and host have diverged`, + context: { + repo: result.conflict.repo, + conflict_type: result.conflict.type, + local_sha: result.conflict.localSha, + remote_sha: result.conflict.remoteSha, + detail: result.conflict.detail, + }, + }); + } else if (result.synced && result.buildUpdated) { + if (panel) panel.postCompat("compatible", "Synced — reload window"); + const reload = await vscode.window.showInformationMessage("Sync complete. Reload?", "Reload Window"); + if (reload === "Reload Window") void vscode.commands.executeCommand("workbench.action.reloadWindow"); + } else { + if (panel) panel.postCompat("compatible", "Up to date"); + void vscode.window.showInformationMessage("Already in sync with host"); + } + })); + // Activation-time fleet drift warning (darwin only). If this machine is a fleet // client but the guard is missing/stale or the tunnel is mis-tuned, surface // ONE warning with a Fix action — don't silently fork. @@ -1368,12 +1704,15 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (failed.length === 0) return; const detail = failed.map((c) => `${c.name}: ${c.detail}`).join("; "); opencodeChannel.appendLine(`[fleet] drift detected: ${detail}`); - void vscode.window - .showWarningMessage(`Amicode fleet drift — ${failed.map((c) => c.name).join(", ")}: ${failed[0].detail}`, "Fix fleet", "Show details") - .then((pick) => { - if (pick === "Fix fleet") void runFleetRepair(); - else if (pick === "Show details") opencodeChannel.show(); - }); + notifyFleetIssue({ + kind: "fleet-drift", + summary: `Fleet drift — ${failed.map((c) => c.name).join(", ")}`, + context: { + failed_checks: failed.map((c) => c.name).join(", "), + details: detail, + platform: "darwin", + }, + }); } catch (e) { opencodeChannel.appendLine(`[fleet] drift check failed: ${(e as Error).message}`); } diff --git a/packages/extension/src/fleet_bridge.ts b/packages/extension/src/fleet_bridge.ts new file mode 100644 index 00000000..932c52d4 --- /dev/null +++ b/packages/extension/src/fleet_bridge.ts @@ -0,0 +1,160 @@ +// Fleet Bridge — extension-side bridge protocol for fleet state push and +// fleet action handling. The extension pushes FleetStateSnapshot to the app +// (via postMessage through the chat bridge), and receives fleet-action commands +// back from the app's context menu. +// +// Part of #358 (Fleet: sessions dropdown enrichment). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { FLEET_REGISTRY_DIR } from "./fleet_launch"; + +// ============================================================================ +// Fleet State Snapshot (extension → app) +// ============================================================================ + +export interface FleetSessionState { + session_id: string; + state: string; + profile_name: string; + tokens: number; + host: string; + current_step: string; +} + +export interface FleetStateSnapshot { + type: "fleet-state"; + payload: { + sessions: FleetSessionState[]; + }; +} + +/** Build a FleetStateSnapshot from the current fleet records. */ +export function buildFleetStateSnapshot(dir: string = FLEET_REGISTRY_DIR): FleetStateSnapshot { + const sessions: FleetSessionState[] = []; + try { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".toml") && !f.startsWith(".")); + for (const file of files) { + try { + const raw = fs.readFileSync(path.join(dir, file), "utf8"); + const sessionId = file.replace(/\.toml$/, ""); + const stateMatch = raw.match(/^state\s*=\s*"([^"]+)"/m); + const tokensMatch = raw.match(/^tokens\s*=\s*(\d+)/m); + const hostMatch = raw.match(/^host\s*=\s*"([^"]+)"/m); + const stepMatch = raw.match(/^current_step\s*=\s*"([^"]*)"/m); + // Profile name is nested under [profile] + const nameMatch = raw.match(/^name\s*=\s*"([^"]+)"/m); + + if (stateMatch) { + sessions.push({ + session_id: sessionId, + state: stateMatch[1], + profile_name: nameMatch?.[1] ?? "", + tokens: tokensMatch ? parseInt(tokensMatch[1], 10) : 0, + host: hostMatch?.[1] ?? "", + current_step: stepMatch?.[1] ?? "", + }); + } + } catch { + // Skip unreadable records + } + } + } catch { + // Directory doesn't exist + } + return { type: "fleet-state", payload: { sessions } }; +} + +// ============================================================================ +// Fleet Action (app → extension) +// ============================================================================ + +export interface FleetAction { + type: "fleet-action"; + verb: "steer" | "stop" | "re-tier" | "view-details"; + session_id: string; + params: Record; +} + +/** Validate and type-narrow a fleet action message from the bridge. */ +export function parseFleetAction(msg: unknown): FleetAction | null { + if (!msg || typeof msg !== "object") return null; + const m = msg as Record; + if (m.type !== "fleet-action") return null; + if (typeof m.verb !== "string") return null; + if (typeof m.session_id !== "string" || m.session_id === "") return null; + + const verb = m.verb as string; + if (!["steer", "stop", "re-tier", "view-details"].includes(verb)) return null; + + return { + type: "fleet-action", + verb: verb as FleetAction["verb"], + session_id: m.session_id as string, + params: (m.params && typeof m.params === "object" ? m.params : {}) as Record, + }; +} + +/** Write a signal file for a fleet action (same path as CLI verbs). + * Signal files are the single-writer mechanism: we never write records directly. */ +export function enqueueFleetSignal( + action: FleetAction, + dir: string = FLEET_REGISTRY_DIR, +): string { + const signalDir = path.join(dir, `${action.session_id}.signal.d`); + fs.mkdirSync(signalDir, { recursive: true }); + const signalFile = path.join(signalDir, `${action.verb}-${Date.now()}.json`); + fs.writeFileSync(signalFile, JSON.stringify({ verb: action.verb, params: action.params, ts: new Date().toISOString() })); + return signalFile; +} + +// ============================================================================ +// Debounced push (extension-side, produces snapshots on record changes) +// ============================================================================ + +export type FleetStatePusher = (snapshot: FleetStateSnapshot) => void; + +/** Create a debounced fleet state watcher. Returns a dispose function. + * Watches the fleet registry directory and pushes snapshots on change. */ +export function createFleetStateWatcher( + push: FleetStatePusher, + opts: { debounceMs?: number; dir?: string } = {}, +): { dispose: () => void } { + const debounceMs = opts.debounceMs ?? 500; + const dir = opts.dir ?? FLEET_REGISTRY_DIR; + let timer: ReturnType | null = null; + let watcher: fs.FSWatcher | null = null; + + const debouncedPush = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + push(buildFleetStateSnapshot(dir)); + }, debounceMs); + }; + + try { + fs.mkdirSync(dir, { recursive: true }); + watcher = fs.watch(dir, () => debouncedPush()); + } catch { + // Watch may fail; fleet state still works via explicit refresh + } + + return { + dispose() { + if (timer) clearTimeout(timer); + watcher?.close(); + }, + }; +} + +// ============================================================================ +// Bridge allowlist additions +// ============================================================================ + +/** The bridge message kinds this module adds to the protocol. */ +export const FLEET_BRIDGE_KINDS = { + /** Extension → app: fleet state snapshot (inbound to the app). */ + inbound: "fleet-state", + /** App → extension: fleet action command (outbound from the app). */ + outbound: "fleet-action", +} as const; diff --git a/packages/extension/src/fleet_compat.ts b/packages/extension/src/fleet_compat.ts new file mode 100644 index 00000000..1c4855b7 --- /dev/null +++ b/packages/extension/src/fleet_compat.ts @@ -0,0 +1,187 @@ +// Fleet Version Compatibility — protocol negotiation between client and server. +// The client checks the server's capabilities on connect (via the health probe) +// and determines the compatibility state: compatible, degraded, or incompatible. +// +// Design: Option B (semver + capability negotiation with standalone fallback). +// - Compatible: same major version, full feature set +// - Degraded: minor mismatch, basic sessions work, new features don't render +// - Incompatible: major version mismatch, warn and offer Go Standalone + +import type { SshExec } from "./fleet_ssh"; +import { defaultSshExec } from "./fleet_ssh"; + +// ============================================================================ +// Version + Capabilities +// ============================================================================ + +/** The capabilities a server can advertise. Additive — new capabilities are + * added without removing old ones. A client ignores capabilities it doesn't + * understand and only offers UI for the ones it knows. */ +export const KNOWN_CAPABILITIES = [ + "sessions", // core: create/list/view sessions + "fleet-state", // push fleet state snapshots + "fleet-action", // accept fleet action signals (steer/stop/re-tier) + "profiles", // fleet profiles CRUD + "host-settings", // remote host settings read/write + "sweep", // sweep orphaned sessions + "topology", // topology graph data +] as const; + +export type Capability = (typeof KNOWN_CAPABILITIES)[number]; + +export interface ServerInfo { + version: string; + schema: number; + capabilities: string[]; +} + +export type CompatState = "compatible" | "degraded" | "incompatible"; + +export interface CompatResult { + state: CompatState; + serverVersion: string; + clientVersion: string; + missingCapabilities: string[]; + message: string; +} + +// ============================================================================ +// Semver parsing (minimal — major.minor.patch) +// ============================================================================ + +interface SemVer { + major: number; + minor: number; + patch: number; +} + +export function parseSemVer(version: string): SemVer | null { + const m = version.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; +} + +// ============================================================================ +// Compatibility check (pure — unit-testable) +// ============================================================================ + +/** Determine the compatibility state between client and server. + * - Same major version → compatible (or degraded if server has newer capabilities) + * - Different major version → incompatible */ +export function checkCompatibility( + clientVersion: string, + serverInfo: ServerInfo, + clientCapabilities: readonly string[] = KNOWN_CAPABILITIES, +): CompatResult { + const client = parseSemVer(clientVersion); + const server = parseSemVer(serverInfo.version); + + if (!client || !server) { + return { + state: "degraded", + serverVersion: serverInfo.version, + clientVersion, + missingCapabilities: [], + message: "Could not parse version — operating in degraded mode", + }; + } + + // Major version mismatch → incompatible + if (client.major !== server.major) { + return { + state: "incompatible", + serverVersion: serverInfo.version, + clientVersion, + missingCapabilities: serverInfo.capabilities.filter((c) => !clientCapabilities.includes(c)), + message: `Major version mismatch: client v${clientVersion}, server v${serverInfo.version}. Go Standalone or update your extension.`, + }; + } + + // Same major — check if server has capabilities the client doesn't know + const unknown = serverInfo.capabilities.filter((c) => !clientCapabilities.includes(c)); + + if (unknown.length > 0 || server.minor > client.minor) { + return { + state: "degraded", + serverVersion: serverInfo.version, + clientVersion, + missingCapabilities: unknown, + message: `Server is newer (v${serverInfo.version} vs v${clientVersion}). Basic features work — rebuild extension for full capabilities.`, + }; + } + + return { + state: "compatible", + serverVersion: serverInfo.version, + clientVersion, + missingCapabilities: [], + message: "Fully compatible", + }; +} + +// ============================================================================ +// Server probe (fetches ServerInfo from the canonical server) +// ============================================================================ + +/** Probe the server's version endpoint over SSH (reads a version file the + * server writes on start). Falls back to inferring from the binary --version. */ +export async function probeServerInfo( + target: string, + opts: { exec?: SshExec; port?: number } = {}, +): Promise { + const exec = opts.exec ?? defaultSshExec; + + // Try the fleet version file first (written by the server on start) + const versionFileResult = await exec(target, "cat ~/.amico/ops/fleet/server_version.json 2>/dev/null"); + if (versionFileResult.ok && versionFileResult.stdout.trim()) { + try { + const info = JSON.parse(versionFileResult.stdout.trim()); + if (info.version && Array.isArray(info.capabilities)) { + return { + version: String(info.version), + schema: Number(info.schema ?? 1), + capabilities: info.capabilities.map(String), + }; + } + } catch { + // Fall through + } + } + + // Fallback: probe the binary's version + const versionResult = await exec(target, "~/.local/bin/opencode --version 2>/dev/null || opencode --version 2>/dev/null"); + if (versionResult.ok && versionResult.stdout.trim()) { + const version = versionResult.stdout.trim().replace(/^v/, ""); + return { + version, + schema: 1, + capabilities: ["sessions"], // Assume minimal if no version file + }; + } + + return null; +} + +/** Write the server version file (called during server setup/rebuild). + * The server writes this on start so clients can probe without hitting the + * HTTP API (which may not be up yet during health checks). */ +export function buildServerVersionFile(version: string, capabilities?: string[]): string { + const info: ServerInfo = { + version, + schema: 1, + capabilities: capabilities ?? [...KNOWN_CAPABILITIES], + }; + return JSON.stringify(info, null, 2); +} + +// ============================================================================ +// Client-side state (used by the Fleet Panel to show compatibility status) +// ============================================================================ + +export interface FleetCompatState { + checked: boolean; + result?: CompatResult; +} + +/** Get the current extension version from package.json (compile-time constant). */ +export const CLIENT_VERSION = "0.2.1"; // Matches package.json version diff --git a/packages/extension/src/fleet_fallback.ts b/packages/extension/src/fleet_fallback.ts index ce4cbc36..6639f357 100644 --- a/packages/extension/src/fleet_fallback.ts +++ b/packages/extension/src/fleet_fallback.ts @@ -69,7 +69,9 @@ export function writeFleetConfig(config: FleetConfig, p: string = FLEET_CONFIG_P * potential re-enrollment. Removes legacy fallback.json if present. */ export function goStandalone(opts: { previousBinary?: string; previousPort?: number; path?: string } = {}): FleetConfig { const p = opts.path ?? FLEET_CONFIG_PATH; + const existing = readFleetConfig(p); const config: FleetConfig = { + ...existing, role: "standalone", previousBinary: opts.previousBinary, previousPort: opts.previousPort, diff --git a/packages/extension/src/fleet_health.ts b/packages/extension/src/fleet_health.ts index ab785216..7d55c684 100644 --- a/packages/extension/src/fleet_health.ts +++ b/packages/extension/src/fleet_health.ts @@ -65,17 +65,19 @@ export function checkFleetGuard( return { name: "Fleet guard", ok: true, detail: `installed and in sync (${installedGuardPath})` }; } -/** Settings check: amicode.opencodeBinary must point at the guard and opencodePort must match fleet config. */ +/** Settings check: amicode.opencodeBinary must point at the guard. + * NOTE: amicode.opencodePort is NOT checked — in fleet-client mode the extension + * reads the tunnel port from fleet.json (canonical.port), not from the VS Code + * setting. The port setting is the user's standalone preference and is never + * touched by fleet enrollment (preserves the local session during transition). */ export function checkFleetSettings( configuredBinary: string, - configuredPort: number, + _configuredPort: number, opts: { platform?: string; fleetConfig?: FleetConfig | null } = {}, ): FleetCheck { if ((opts.platform ?? process.platform) !== "darwin") { return { name: "Fleet settings", ok: true, detail: "skipped (not darwin)" }; } - const cfg = opts.fleetConfig ?? readFleetConfig(); - const wantPort = cfg?.canonical?.port ?? 4096; const wantBinary = FLEET_GUARD_INSTALL; // Empty binary = vendored default → on a fleet client this would spawn a fork, so flag it. if (!configuredBinary || configuredBinary.trim() === "") { @@ -97,15 +99,7 @@ export function checkFleetSettings( fix: `set amicode.opencodeBinary to ${wantBinary} (scope: machine)`, }; } - if (configuredPort !== wantPort) { - return { - name: "Fleet settings", - ok: false, - detail: `amicode.opencodePort is ${configuredPort}, expected ${wantPort} (tunnel port)`, - fix: `set amicode.opencodePort to ${wantPort} (scope: machine)`, - }; - } - return { name: "Fleet settings", ok: true, detail: `guard + port ${wantPort} (machine scope)` }; + return { name: "Fleet settings", ok: true, detail: `guard binary set (machine scope)` }; } /** Tunnel plist check: ServerAliveInterval 15, CountMax 2, TCPKeepAlive yes, correct port forward. */ diff --git a/packages/extension/src/fleet_host_settings.ts b/packages/extension/src/fleet_host_settings.ts new file mode 100644 index 00000000..b26eb1fb --- /dev/null +++ b/packages/extension/src/fleet_host_settings.ts @@ -0,0 +1,190 @@ +// Fleet Host Settings — read/write canonical server configuration over SSH +// (or locally when this machine IS the server). Settings: DB path, port, +// binary path, log directory. Changes that need a restart show an indicator. +// +// Part of #355 (Fleet Panel: remote host settings). + +import { readFleetConfig, getFleetRole } from "./fleet_fallback"; +import type { SshExec } from "./fleet_ssh"; +import { defaultSshExec } from "./fleet_ssh"; + +export interface HostSettings { + dbPath: string; + port: number; + binaryPath: string; + logDir: string; +} + +/** Settings that require a server restart when changed. */ +export const RESTART_REQUIRED_SETTINGS: (keyof HostSettings)[] = ["dbPath", "port", "binaryPath"]; + +/** Determine which settings would require a restart if changed. */ +export function settingsNeedingRestart( + current: HostSettings, + updated: HostSettings, +): (keyof HostSettings)[] { + return RESTART_REQUIRED_SETTINGS.filter((k) => current[k] !== updated[k]); +} + +/** Validate host settings. Returns array of error messages (empty = valid). */ +export function validateHostSettings(settings: Partial): string[] { + const errors: string[] = []; + if (settings.port !== undefined) { + if (!Number.isInteger(settings.port) || settings.port < 1024 || settings.port > 65535) { + errors.push("Port must be an integer between 1024 and 65535"); + } + } + if (settings.dbPath !== undefined && !settings.dbPath.startsWith("/")) { + errors.push("Database path must be absolute"); + } + if (settings.binaryPath !== undefined && !settings.binaryPath.startsWith("/")) { + errors.push("Binary path must be absolute"); + } + if (settings.logDir !== undefined && !settings.logDir.startsWith("/")) { + errors.push("Log directory must be absolute"); + } + return errors; +} + +/** The remote config file path on the canonical server. */ +const REMOTE_CONFIG_PATH = "~/.amico/ops/fleet/server_config.json"; + +/** Read host settings from the canonical server. + * - If role=server (this machine): reads locally. + * - If role=client: reads over SSH. */ +export async function readHostSettings(opts: { + exec?: SshExec; + localRead?: (path: string) => string; +} = {}): Promise { + const role = getFleetRole(); + const config = readFleetConfig(); + + if (role === "standalone") return null; + + if (role === "server") { + // Read locally + const localRead = opts.localRead ?? ((p: string) => { + const fs = require("node:fs"); + return fs.readFileSync(p.replace("~", require("node:os").homedir()), "utf8"); + }); + try { + const raw = localRead(REMOTE_CONFIG_PATH); + return parseHostSettings(raw); + } catch { + return defaultHostSettings(config?.canonical?.port ?? 4096); + } + } + + // Client: read over SSH + const exec = opts.exec ?? defaultSshExec; + const target = config?.canonical?.sshAlias ?? config?.canonical?.host ?? ""; + if (!target) return null; + + const result = await exec(target, `cat ${REMOTE_CONFIG_PATH} 2>/dev/null`); + if (result.ok && result.stdout) { + return parseHostSettings(result.stdout); + } + return defaultHostSettings(config?.canonical?.port ?? 4096); +} + +/** Write host settings to the canonical server. + * - If role=server: writes locally. + * - If role=client: writes over SSH. */ +export async function writeHostSettings( + settings: HostSettings, + opts: { exec?: SshExec; localWrite?: (path: string, content: string) => void } = {}, +): Promise<{ ok: boolean; error?: string }> { + const errors = validateHostSettings(settings); + if (errors.length > 0) return { ok: false, error: errors[0] }; + + const role = getFleetRole(); + const config = readFleetConfig(); + const json = JSON.stringify(settings, null, 2); + + if (role === "server") { + const localWrite = opts.localWrite ?? ((p: string, content: string) => { + const fs = require("node:fs"); + const path = require("node:path"); + const resolved = p.replace("~", require("node:os").homedir()); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + const tmp = `${resolved}.tmp`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, resolved); + }); + try { + localWrite(REMOTE_CONFIG_PATH, json); + return { ok: true }; + } catch (err) { + return { ok: false, error: String(err) }; + } + } + + // Client: write over SSH + const exec = opts.exec ?? defaultSshExec; + const target = config?.canonical?.sshAlias ?? config?.canonical?.host ?? ""; + if (!target) return { ok: false, error: "No canonical server configured" }; + + const result = await exec(target, + `mkdir -p ~/.amico/ops/fleet && cat > ${REMOTE_CONFIG_PATH}.tmp << 'CFGEOF'\n${json}\nCFGEOF\nmv ${REMOTE_CONFIG_PATH}.tmp ${REMOTE_CONFIG_PATH}`); + if (!result.ok) return { ok: false, error: result.stderr || "SSH write failed" }; + return { ok: true }; +} + +/** Restart the canonical server service (over SSH for clients, locally for servers). */ +export async function restartServer(opts: { + exec?: SshExec; + platform?: "darwin" | "linux"; +} = {}): Promise<{ ok: boolean; error?: string }> { + const role = getFleetRole(); + const config = readFleetConfig(); + const platform = opts.platform ?? (process.platform === "darwin" ? "darwin" : "linux"); + const exec = opts.exec ?? defaultSshExec; + + const cmd = platform === "darwin" + ? "launchctl stop co.harmoniqs.amico-server 2>/dev/null; sleep 1; launchctl start co.harmoniqs.amico-server" + : "systemctl --user restart amico-server.service"; + + if (role === "server") { + // Execute locally + const cp = require("node:child_process"); + try { + cp.execSync(cmd, { timeout: 10000 }); + return { ok: true }; + } catch (err) { + return { ok: false, error: String(err) }; + } + } + + // Client: restart over SSH + const target = config?.canonical?.sshAlias ?? config?.canonical?.host ?? ""; + if (!target) return { ok: false, error: "No canonical server configured" }; + + const result = await exec(target, cmd); + if (!result.ok) return { ok: false, error: result.stderr || "Restart failed" }; + return { ok: true }; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function parseHostSettings(json: string): HostSettings | null { + try { + const data = JSON.parse(json); + return { + dbPath: String(data.dbPath ?? ""), + port: Number(data.port ?? 4096), + binaryPath: String(data.binaryPath ?? ""), + logDir: String(data.logDir ?? ""), + }; + } catch { + return null; + } +} + +function defaultHostSettings(port: number): HostSettings { + return { + dbPath: "~/.amico/ops/sessions.db", + port, + binaryPath: "/usr/local/bin/opencode", + logDir: "/tmp/amico-server-logs", + }; +} diff --git a/packages/extension/src/fleet_launch.ts b/packages/extension/src/fleet_launch.ts new file mode 100644 index 00000000..495580d7 --- /dev/null +++ b/packages/extension/src/fleet_launch.ts @@ -0,0 +1,211 @@ +// Fleet Launch & Stats — session launch from profile and aggregate stats +// computation. The launch creates a FleetRecord in the fleet registry dir +// (~/.amico/ops/fleet/). Stats are computed from all records. +// +// Part of #357 (Fleet Panel: session launch from profile + aggregate stats). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import { hostname } from "node:os"; +import { stringify as stringifyToml } from "smol-toml"; +import { FLEET_DIR } from "./fleet_fallback"; +import type { FleetProfile } from "./fleet_profiles"; +import type { FleetStats } from "./fleet_panel"; + +export const FLEET_REGISTRY_DIR = FLEET_DIR; + +export interface FleetRecordForLaunch { + schema: number; + session_id: string; + state: "spooling"; + current_step: string; + started: string; + tokens: number; + runtime: number; + respooled_to: string; + pid: number; + host: string; + profile: { + name: string; + base: string; + model: string; + variant: string; + task_type: string; + skills: string[]; + gates: string[]; + permissions: Record; + }; +} + +/** Generate a unique session ID (ses_ + 12 random hex chars). */ +export function generateSessionId(): string { + return `ses_${crypto.randomBytes(6).toString("hex")}`; +} + +/** Create a FleetRecord for a new session launched from a profile. + * The record is in `spooling` state — the harness will transition it. */ +export function buildLaunchRecord(profile: FleetProfile): FleetRecordForLaunch { + return { + schema: 1, + session_id: generateSessionId(), + state: "spooling", + current_step: "", + started: new Date().toISOString(), + tokens: 0, + runtime: 0, + respooled_to: "", + pid: process.pid, + host: hostname(), + profile: { + name: profile.name, + base: profile.base, + model: profile.model, + variant: profile.variant, + task_type: profile.task_type, + skills: profile.skills, + gates: profile.gates, + permissions: profile.permissions, + }, + }; +} + +/** Write a fleet record atomically (tmp + rename). */ +export function writeFleetRecord( + record: FleetRecordForLaunch, + dir: string = FLEET_REGISTRY_DIR, +): string { + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${record.session_id}.toml`); + const tmp = `${filePath}.tmp-${process.pid}`; + const toml = stringifyToml(record as unknown as Record); + fs.writeFileSync(tmp, toml); + fs.renameSync(tmp, filePath); + return filePath; +} + +/** Launch a session from a profile: build the record, write it. */ +export function launchFromProfile( + profile: FleetProfile, + dir: string = FLEET_REGISTRY_DIR, +): { sessionId: string; recordPath: string } { + const record = buildLaunchRecord(profile); + const recordPath = writeFleetRecord(record, dir); + return { sessionId: record.session_id, recordPath }; +} + +// ============================================================================ +// Aggregate Stats +// ============================================================================ + +interface SimpleRecord { + state: string; + tokens: number; + started: string; +} + +/** Read all fleet records (simple: state + tokens + started). */ +export function readAllSimpleRecords(dir: string = FLEET_REGISTRY_DIR): SimpleRecord[] { + try { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".toml") && !f.startsWith(".")); + const records: SimpleRecord[] = []; + for (const file of files) { + try { + const raw = fs.readFileSync(path.join(dir, file), "utf8"); + // Simple parse: extract state, tokens, started + const stateMatch = raw.match(/^state\s*=\s*"([^"]+)"/m); + const tokensMatch = raw.match(/^tokens\s*=\s*(\d+)/m); + const startedMatch = raw.match(/^started\s*=\s*"([^"]+)"/m); + if (stateMatch) { + records.push({ + state: stateMatch[1], + tokens: tokensMatch ? parseInt(tokensMatch[1], 10) : 0, + started: startedMatch?.[1] ?? "", + }); + } + } catch { + // Skip unreadable files + } + } + return records; + } catch { + return []; + } +} + +/** Compute aggregate fleet stats from all records. */ +export function computeFleetStats( + records: SimpleRecord[], + opts: { today?: string } = {}, +): FleetStats { + const todayPrefix = opts.today ?? new Date().toISOString().slice(0, 10); // YYYY-MM-DD + + const active = records.filter((r) => r.state === "running" || r.state === "blocked" || r.state === "spooling"); + const running = records.filter((r) => r.state === "running"); + const blocked = records.filter((r) => r.state === "blocked"); + const tokensToday = records + .filter((r) => r.started.startsWith(todayPrefix)) + .reduce((sum, r) => sum + r.tokens, 0); + + return { + active: active.length, + running: running.length, + blocked: blocked.length, + tokensToday, + }; +} + +/** Compute stats from the fleet registry directory. */ +export function getFleetStats(dir: string = FLEET_REGISTRY_DIR): FleetStats { + const records = readAllSimpleRecords(dir); + return computeFleetStats(records); +} + +// ============================================================================ +// Sweep — mark orphaned crashed sessions +// ============================================================================ + +/** Check if a process is alive by sending signal 0. */ +export function isProcessAlive(pid: number): boolean { + if (pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** Sweep: find records whose pid is not alive and mark them as crashed. + * Returns the session IDs that were swept. */ +export function sweepCrashed(dir: string = FLEET_REGISTRY_DIR): string[] { + const swept: string[] = []; + try { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".toml") && !f.startsWith(".")); + for (const file of files) { + const filePath = path.join(dir, file); + try { + const raw = fs.readFileSync(filePath, "utf8"); + const stateMatch = raw.match(/^state\s*=\s*"([^"]+)"/m); + const pidMatch = raw.match(/^pid\s*=\s*(\d+)/m); + const state = stateMatch?.[1]; + const pid = pidMatch ? parseInt(pidMatch[1], 10) : 0; + + // Only sweep running/blocked sessions with dead pids + if ((state === "running" || state === "blocked") && pid > 0 && !isProcessAlive(pid)) { + // Update state to crashed (atomic write) + const updated = raw.replace(/^state\s*=\s*"[^"]+"/m, 'state = "crashed"'); + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + swept.push(file.replace(/\.toml$/, "")); + } + } catch { + // Skip unreadable files + } + } + } catch { + // Directory doesn't exist = nothing to sweep + } + return swept; +} diff --git a/packages/extension/src/fleet_panel.ts b/packages/extension/src/fleet_panel.ts new file mode 100644 index 00000000..bf576909 --- /dev/null +++ b/packages/extension/src/fleet_panel.ts @@ -0,0 +1,185 @@ +// Fleet Panel — a VS Code native webview in the activity bar showing fleet +// topology, profiles, and aggregate stats. Follows the Device Inspector pattern +// (WebviewViewProvider + typed postMessage + TS-composed DOM). Always visible +// (no context-key gate) — standalone users still benefit from profiles + launch. +// +// Part of #350 (Fleet Panel & Profile UI). + +import * as vscode from "vscode"; +import { inspectorResourceRootDirs } from "./opencode_paths"; +import { getFleetRole, readFleetConfig } from "./fleet_fallback"; + +// ============================================================================ +// Message protocol (host ↔ webview) +// ============================================================================ + +/** Messages the host sends TO the webview. */ +export type FleetHostMessage = + | { type: "topology"; nodes: FleetNode[]; edges: FleetEdge[] } + | { type: "profiles"; profiles: FleetProfileSummary[] } + | { type: "stats"; stats: FleetStats } + | { type: "role"; role: "standalone" | "server" | "client"; hasCanonical: boolean } + | { type: "compat"; state: "compatible" | "degraded" | "incompatible"; message: string }; + +/** Messages the webview sends TO the host. */ +export type FleetWebviewMessage = + | { type: "action"; action: string; payload?: Record } + | { type: "log"; text: string }; + +export interface FleetNode { + id: string; + hostname: string; + role: "server" | "client"; + isLocal: boolean; + healthy: boolean; + lastSeen?: string; + sessionCount: number; +} + +export interface FleetEdge { + from: string; + to: string; + connected: boolean; +} + +export interface FleetProfileSummary { + slug: string; + name: string; + model: string; + variant: string; +} + +export interface FleetStats { + active: number; + running: number; + blocked: number; + tokensToday: number; +} + +// ============================================================================ +// FleetPanelView — the WebviewViewProvider +// ============================================================================ + +let FLEET_PANEL: FleetPanelView | undefined; + +export class FleetPanelView implements vscode.WebviewViewProvider { + private view?: vscode.WebviewView; + private currentRole: "standalone" | "server" | "client" = "standalone"; + + constructor(private readonly ctx: vscode.ExtensionContext) { + this.currentRole = getFleetRole(); + } + + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { + enableScripts: true, + localResourceRoots: inspectorResourceRootDirs(this.ctx.extensionUri.fsPath).map( + (d) => vscode.Uri.file(d), + ), + }; + view.webview.html = this.renderHtml(view.webview); + + const msgSub = view.webview.onDidReceiveMessage((msg: FleetWebviewMessage) => { + this.handleMessage(msg); + }); + view.onDidDispose(() => { + this.view = undefined; + msgSub.dispose(); + }); + + // Push initial state + this.pushRole(); + } + + // ── Public surface (used by the extension to push data) ────────────────── + + /** Push a topology update to the webview. */ + postTopology(nodes: FleetNode[], edges: FleetEdge[]): void { + this.post({ type: "topology", nodes, edges }); + } + + /** Push updated profile list to the webview. */ + postProfiles(profiles: FleetProfileSummary[]): void { + this.post({ type: "profiles", profiles }); + } + + /** Push aggregate stats to the webview. */ + postStats(stats: FleetStats): void { + this.post({ type: "stats", stats }); + } + + /** Push compatibility status to the webview. */ + postCompat(state: "compatible" | "degraded" | "incompatible", message: string): void { + this.post({ type: "compat", state, message }); + } + + /** Push the current fleet role (standalone/server/client). */ + pushRole(): void { + this.currentRole = getFleetRole(); + const cfg = readFleetConfig(); + const hasCanonical = !!(cfg?.canonical?.host); + this.post({ type: "role", role: this.currentRole, hasCanonical }); + } + + // ── Private ────────────────────────────────────────────────────────────── + + private post(msg: FleetHostMessage): void { + if (this.view) void this.view.webview.postMessage(msg); + } + + private handleMessage(msg: FleetWebviewMessage): void { + if (msg.type === "log") return; // dev logging, ignore + if (msg.type === "action") { + // Dispatch actions from the webview (wizard steps, profile CRUD, etc.) + void vscode.commands.executeCommand(`amicode.fleet.${msg.action}`, msg.payload); + } + } + + private renderHtml(webview: vscode.Webview): string { + const uri = (...parts: string[]) => + webview.asWebviewUri(vscode.Uri.joinPath(this.ctx.extensionUri, ...parts)); + const nonce = newNonce(); + return /* html */ ` + + + + + + + + + + +`; + } +} + +function newNonce(): string { + let s = ""; + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +// ============================================================================ +// Registration + access +// ============================================================================ + +export function registerFleetPanel(ctx: vscode.ExtensionContext): FleetPanelView { + FLEET_PANEL = new FleetPanelView(ctx); + ctx.subscriptions.push( + vscode.window.registerWebviewViewProvider("amicode.fleet", FLEET_PANEL, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + return FLEET_PANEL; +} + +export function getFleetPanel(): FleetPanelView | undefined { + return FLEET_PANEL; +} diff --git a/packages/extension/src/fleet_profile_manager.ts b/packages/extension/src/fleet_profile_manager.ts new file mode 100644 index 00000000..3d0ade23 --- /dev/null +++ b/packages/extension/src/fleet_profile_manager.ts @@ -0,0 +1,163 @@ +// Fleet Profile Manager — watches the profiles directory and pushes updates +// to the Fleet Panel. Handles CRUD commands from the webview. +// +// Part of #356 (Fleet Panel: Fleet Profiles CRUD). + +import * as fs from "node:fs"; +import * as vscode from "vscode"; +import { + listProfiles, + writeProfile, + deleteProfile, + duplicateProfile, + slugify, + slugExists, + validateProfile, + PROFILES_DIR, + type FleetProfile, +} from "./fleet_profiles"; +import type { FleetPanelView, FleetProfileSummary } from "./fleet_panel"; + +export class FleetProfileManager { + private watcher?: fs.FSWatcher; + private panel: FleetPanelView; + + constructor(panel: FleetPanelView) { + this.panel = panel; + this.startWatching(); + this.pushProfiles(); + } + + /** Push the current profile list to the panel. */ + pushProfiles(): void { + const profiles = listProfiles(); + const summaries: FleetProfileSummary[] = profiles.map((p) => ({ + slug: p.slug, + name: p.profile.name, + model: p.profile.model, + variant: p.profile.variant, + })); + this.panel.postProfiles(summaries); + } + + /** Handle a create-profile action from the webview. */ + handleCreate(payload: Record): { ok: boolean; error?: string } { + const name = String(payload.name ?? ""); + const slug = slugify(name); + + const profile: Partial = { + name, + model: String(payload.model ?? ""), + variant: String(payload.variant ?? ""), + base: String(payload.base ?? "pulse-designer"), + task_type: String(payload.task_type ?? "interactive"), + skills: Array.isArray(payload.skills) ? payload.skills.map(String) : [], + gates: Array.isArray(payload.gates) ? payload.gates.map(String) : [], + permissions: { bash: "allow", file_write: "allow" }, + }; + + const errors = validateProfile(profile); + if (errors.length > 0) return { ok: false, error: errors[0] }; + if (slugExists(slug)) return { ok: false, error: `Profile "${slug}" already exists` }; + + writeProfile({ schema: 1, ...profile } as FleetProfile, slug); + this.pushProfiles(); + return { ok: true }; + } + + /** Handle an edit-profile action from the webview. */ + handleEdit(payload: Record): { ok: boolean; error?: string } { + const slug = String(payload.slug ?? ""); + if (!slug || !slugExists(slug)) return { ok: false, error: "Profile not found" }; + + const profile: FleetProfile = { + schema: 1, + name: String(payload.name ?? ""), + model: String(payload.model ?? ""), + variant: String(payload.variant ?? ""), + base: String(payload.base ?? "pulse-designer"), + task_type: String(payload.task_type ?? "interactive"), + skills: Array.isArray(payload.skills) ? payload.skills.map(String) : [], + gates: Array.isArray(payload.gates) ? payload.gates.map(String) : [], + permissions: { bash: "allow", file_write: "allow" }, + }; + + const errors = validateProfile(profile); + if (errors.length > 0) return { ok: false, error: errors[0] }; + + writeProfile(profile, slug); + this.pushProfiles(); + return { ok: true }; + } + + /** Handle a duplicate-profile action from the webview. */ + handleDuplicate(payload: Record): { ok: boolean; error?: string } { + const slug = String(payload.slug ?? ""); + const newSlug = duplicateProfile(slug); + if (!newSlug) return { ok: false, error: "Source profile not found" }; + this.pushProfiles(); + return { ok: true }; + } + + /** Handle a delete-profile action from the webview. */ + handleDelete(payload: Record): { ok: boolean; error?: string } { + const slug = String(payload.slug ?? ""); + if (!deleteProfile(slug)) return { ok: false, error: "Profile not found" }; + this.pushProfiles(); + return { ok: true }; + } + + /** Start watching the profiles directory for external changes. */ + private startWatching(): void { + try { + fs.mkdirSync(PROFILES_DIR, { recursive: true }); + this.watcher = fs.watch(PROFILES_DIR, () => { + this.pushProfiles(); + }); + } catch { + // Directory watch may fail on some systems; profiles still work via explicit refresh + } + } + + dispose(): void { + this.watcher?.close(); + } +} + +/** Register profile CRUD commands and the profile manager. */ +export function registerFleetProfiles( + ctx: vscode.ExtensionContext, + panel: FleetPanelView, +): FleetProfileManager { + const manager = new FleetProfileManager(panel); + ctx.subscriptions.push({ dispose: () => manager.dispose() }); + + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.fleet.createProfile", (payload?: Record) => { + if (payload) { + const result = manager.handleCreate(payload); + if (!result.ok) void vscode.window.showErrorMessage(`Profile creation failed: ${result.error}`); + } + }), + vscode.commands.registerCommand("amicode.fleet.editProfile", (payload?: Record) => { + if (payload) { + const result = manager.handleEdit(payload); + if (!result.ok) void vscode.window.showErrorMessage(`Profile edit failed: ${result.error}`); + } + }), + vscode.commands.registerCommand("amicode.fleet.duplicateProfile", (payload?: Record) => { + if (payload) { + const result = manager.handleDuplicate(payload); + if (!result.ok) void vscode.window.showErrorMessage(`Profile duplication failed: ${result.error}`); + } + }), + vscode.commands.registerCommand("amicode.fleet.deleteProfile", (payload?: Record) => { + if (payload) { + const result = manager.handleDelete(payload); + if (!result.ok) void vscode.window.showErrorMessage(`Profile deletion failed: ${result.error}`); + } + }), + ); + + return manager; +} diff --git a/packages/extension/src/fleet_profiles.ts b/packages/extension/src/fleet_profiles.ts new file mode 100644 index 00000000..fdb75583 --- /dev/null +++ b/packages/extension/src/fleet_profiles.ts @@ -0,0 +1,154 @@ +// Fleet Profiles — CRUD for fleet profile TOML files stored under +// ~/.amico/ops/fleet/profiles/. Each profile is one TOML file with a fixed +// schema (schema version 1). Pure I/O + serialization; the panel pushes +// profile lists to the webview. +// +// Part of #356 (Fleet Panel: Fleet Profiles CRUD). + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { homedir } from "node:os"; +import { parse, stringify } from "smol-toml"; + +export const PROFILES_DIR = path.join(homedir(), ".amico", "ops", "fleet", "profiles"); + +export interface FleetProfile { + schema: number; + name: string; + base: string; + model: string; + variant: string; + task_type: string; + skills: string[]; + gates: string[]; + permissions: Record; +} + +/** Generate a kebab-case slug from a profile name. */ +export function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Read a single profile from a TOML file. Returns null on parse failure. */ +export function readProfile(filePath: string): FleetProfile | null { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return parseProfile(raw); + } catch { + return null; + } +} + +/** Parse profile from TOML string. */ +export function parseProfile(toml: string): FleetProfile | null { + try { + const data = parse(toml) as Record; + return { + schema: typeof data.schema === "number" ? data.schema : 1, + name: String(data.name ?? ""), + base: String(data.base ?? ""), + model: String(data.model ?? ""), + variant: String(data.variant ?? ""), + task_type: String(data.task_type ?? "interactive"), + skills: Array.isArray(data.skills) ? data.skills.map(String) : [], + gates: Array.isArray(data.gates) ? data.gates.map(String) : [], + permissions: + data.permissions && typeof data.permissions === "object" + ? Object.fromEntries( + Object.entries(data.permissions as Record).map(([k, v]) => [k, String(v)]), + ) + : {}, + }; + } catch { + return null; + } +} + +/** Serialize a profile to TOML string. */ +export function serializeProfile(profile: FleetProfile): string { + return stringify(profile as unknown as Record); +} + +/** List all profiles from the profiles directory. */ +export function listProfiles(dir: string = PROFILES_DIR): { slug: string; profile: FleetProfile }[] { + try { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".toml")); + const results: { slug: string; profile: FleetProfile }[] = []; + for (const file of files) { + const slug = file.replace(/\.toml$/, ""); + const profile = readProfile(path.join(dir, file)); + if (profile) results.push({ slug, profile }); + } + return results; + } catch { + return []; + } +} + +/** Write a profile atomically (tmp + rename). Creates dir if absent. */ +export function writeProfile( + profile: FleetProfile, + slug: string, + dir: string = PROFILES_DIR, +): void { + fs.mkdirSync(dir, { recursive: true }); + const toml = serializeProfile(profile); + const filePath = path.join(dir, `${slug}.toml`); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, toml); + fs.renameSync(tmp, filePath); +} + +/** Check if a slug already exists in the profiles directory. */ +export function slugExists(slug: string, dir: string = PROFILES_DIR): boolean { + try { + return fs.existsSync(path.join(dir, `${slug}.toml`)); + } catch { + return false; + } +} + +/** Delete a profile by slug. */ +export function deleteProfile(slug: string, dir: string = PROFILES_DIR): boolean { + try { + const filePath = path.join(dir, `${slug}.toml`); + fs.unlinkSync(filePath); + return true; + } catch { + return false; + } +} + +/** Duplicate a profile with a "-copy" suffix. Returns the new slug or null on failure. */ +export function duplicateProfile( + slug: string, + dir: string = PROFILES_DIR, +): string | null { + const source = readProfile(path.join(dir, `${slug}.toml`)); + if (!source) return null; + + let newSlug = `${slug}-copy`; + let counter = 2; + while (slugExists(newSlug, dir)) { + newSlug = `${slug}-copy-${counter}`; + counter++; + } + + const newProfile = { ...source, name: `${source.name} (copy)` }; + writeProfile(newProfile, newSlug, dir); + return newSlug; +} + +/** Validate a profile for required fields. Returns array of error messages. */ +export function validateProfile(profile: Partial): string[] { + const errors: string[] = []; + if (!profile.name || profile.name.trim() === "") errors.push("Name is required"); + if (!profile.model || profile.model.trim() === "") errors.push("Model is required"); + const slug = slugify(profile.name ?? ""); + if (!slug) errors.push("Name must produce a valid slug (at least one alphanumeric character)"); + return errors; +} diff --git a/packages/extension/src/fleet_ssh.ts b/packages/extension/src/fleet_ssh.ts new file mode 100644 index 00000000..75271f37 --- /dev/null +++ b/packages/extension/src/fleet_ssh.ts @@ -0,0 +1,115 @@ +// Fleet SSH utilities — shared helpers used by fleet_sync, fleet_compat, and +// fleet_host_settings. Extracted from fleet_wizard.ts when the wizard was +// replaced by the agentic fleet skill (#363). + +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { homedir } from "node:os"; + +// ============================================================================ +// SSH execution helpers (pure, testable with mocked exec) +// ============================================================================ + +export interface SshExecResult { + ok: boolean; + stdout: string; + stderr: string; + code: number | null; +} + +export type SshExec = (target: string, command: string) => Promise; + +/** Default SSH executor: spawns `ssh `. */ +export function defaultSshExec(target: string, command: string): Promise { + return new Promise((resolve) => { + const proc = cp.spawn("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", target, command], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + proc.on("close", (code) => resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code })); + proc.on("error", (err) => resolve({ ok: false, stdout: "", stderr: err.message, code: null })); + }); +} + +/** Default SCP executor: copies a file to the remote. */ +export function scpFile(localPath: string, target: string, remotePath: string): Promise { + return new Promise((resolve) => { + const proc = cp.spawn("scp", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", localPath, `${target}:${remotePath}`], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + proc.on("close", (code) => resolve({ ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code })); + proc.on("error", (err) => resolve({ ok: false, stdout: "", stderr: err.message, code: null })); + }); +} + +// ============================================================================ +// Session DB directory resolution +// ============================================================================ + +/** Resolve the opencode data directory (where session DBs live). + * Uses XDG_DATA_HOME/opencode if set, otherwise ~/.local/share/opencode. + * This mirrors opencode's own resolution in packages/core/src/global.ts: + * `path.join(xdgData, "opencode")` */ +export function resolveSessionDbDir(): string { + const xdgData = process.env.XDG_DATA_HOME ?? path.join(homedir(), ".local", "share"); + return path.join(xdgData, "opencode"); +} + +// ============================================================================ +// Local repo detection — find amicode + opencode repos from the running build +// ============================================================================ + +/** Find the git repo root by walking up from a starting directory. */ +function findGitRoot(startDir: string): string | null { + let dir = startDir; + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(dir, ".git"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** Detect the local amicode repo root from the extension path. + * The extension lives inside the repo — walk up to .git. + * Falls back to ~/harmoniqs/amicode if detection fails. */ +export function detectAmicodeRepoDir(extensionPath?: string): string { + if (extensionPath) { + const root = findGitRoot(extensionPath); + if (root) return root; + } + return path.join(homedir(), "harmoniqs", "amicode"); +} + +/** Detect the local opencode repo root from the configured binary path. + * The binary lives inside the repo — walk up to .git. + * Falls back to ~/harmoniqs/opencode if detection fails. */ +export function detectOpencodeRepoDir(binaryPath?: string): string { + if (binaryPath) { + const root = findGitRoot(path.dirname(binaryPath)); + if (root) return root; + } + return path.join(homedir(), "harmoniqs", "opencode"); +} + +/** Detect both repo paths from the running build context. */ +export function detectLocalRepos(opts?: { extensionPath?: string; binaryPath?: string }): { + amicode: string; + opencode: string; +} { + return { + amicode: detectAmicodeRepoDir(opts?.extensionPath), + opencode: detectOpencodeRepoDir(opts?.binaryPath), + }; +} diff --git a/packages/extension/src/fleet_sync.ts b/packages/extension/src/fleet_sync.ts new file mode 100644 index 00000000..b03771c2 --- /dev/null +++ b/packages/extension/src/fleet_sync.ts @@ -0,0 +1,474 @@ +// Fleet Sync — bidirectional replication with the canonical host. +// +// The host is ground truth in fleet mode. But if the client does dev work in +// standalone mode and pushes to origin, the host absorbs those changes on +// reconnect. Flow: +// +// Fleet mode: host → client (auto-sync, host is truth) +// Standalone: client works independently, pushes to origin +// On reconnect: client pushes to origin → host pulls + rebuilds → client syncs from host +// +// After reconnect sync, both sides are identical again. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as cp from "node:child_process"; +import type { SshExec } from "./fleet_ssh"; +import { defaultSshExec, resolveSessionDbDir, detectLocalRepos } from "./fleet_ssh"; +import { readFleetConfig, getFleetRole } from "./fleet_fallback"; + +// ============================================================================ +// Sync state +// ============================================================================ + +export type SyncStatus = "idle" | "checking" | "syncing" | "up-to-date" | "error"; + +export interface SyncState { + status: SyncStatus; + lastSync?: string; + localSha?: string; + remoteSha?: string; + error?: string; +} + +export interface SyncResult { + synced: boolean; + buildUpdated: boolean; + sessionsUpdated: boolean; + conflict?: SyncConflict; + error?: string; +} + +export interface SyncConflict { + repo: "amicode" | "opencode"; + type: "push-rejected" | "merge-conflict" | "diverged"; + localSha: string; + remoteSha: string; + detail: string; +} + +// ============================================================================ +// SHA check — are we on the same commit as the host? +// ============================================================================ + +/** Get the local amicode repo git SHA. */ +export function getLocalSha(repoDir?: string): string | null { + const dir = repoDir ?? detectLocalRepos().amicode; + try { + return cp.execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8", timeout: 5000 }).trim(); + } catch { + return null; + } +} + +/** Get the host's amicode repo git SHA over SSH. */ +export async function getRemoteSha( + target: string, + opts: { exec?: SshExec } = {}, +): Promise { + const exec = opts.exec ?? defaultSshExec; + const result = await exec(target, "cd ~/harmoniqs/amicode && git rev-parse HEAD 2>/dev/null"); + return result.ok && result.stdout.trim() ? result.stdout.trim() : null; +} + +// ============================================================================ +// Build sync — pull the host's exact SHA and rebuild locally +// ============================================================================ + +/** Pull the host's exact commits and rebuild. After this, client = host. */ +export async function syncBuild( + target: string, + opts: { exec?: SshExec; onProgress?: (step: string) => void } = {}, +): Promise<{ ok: boolean; error?: string }> { + const exec = opts.exec ?? defaultSshExec; + const progress = opts.onProgress ?? (() => {}); + const repos = detectLocalRepos(); + const repoDir = repos.amicode; + const ocRepoDir = repos.opencode; + + // Get the host's exact SHAs + branches + progress("Reading host state..."); + const [amSha, ocSha, amBranch, ocBranch] = await Promise.all([ + exec(target, "cd ~/harmoniqs/amicode && git rev-parse HEAD"), + exec(target, "cd ~/harmoniqs/opencode && git rev-parse HEAD"), + exec(target, "cd ~/harmoniqs/amicode && git rev-parse --abbrev-ref HEAD"), + exec(target, "cd ~/harmoniqs/opencode && git rev-parse --abbrev-ref HEAD"), + ]); + if (!amSha.ok || !ocSha.ok) { + return { ok: false, error: "Could not read host git state" }; + } + + const amicodeTarget = amSha.stdout.trim(); + const opencodeTarget = ocSha.stdout.trim(); + const amicodeBranch = amBranch.ok ? amBranch.stdout.trim() : "main"; + const opencodeBranch = ocBranch.ok ? ocBranch.stdout.trim() : "local/amicode"; + + // Reset opencode to the host's exact state + progress("Syncing opencode..."); + try { + cp.execSync( + `git fetch origin && git checkout ${opencodeBranch} && git reset --hard ${opencodeTarget}`, + { cwd: ocRepoDir, encoding: "utf8", timeout: 60000 }, + ); + } catch (err) { + return { ok: false, error: `opencode sync failed: ${err}` }; + } + + // Reset amicode to the host's exact state + progress("Syncing amicode..."); + try { + cp.execSync( + `git fetch origin && git checkout ${amicodeBranch} && git reset --hard ${amicodeTarget}`, + { cwd: repoDir, encoding: "utf8", timeout: 60000 }, + ); + } catch (err) { + return { ok: false, error: `amicode sync failed: ${err}` }; + } + + // Build opencode binary + progress("Building opencode..."); + try { + cp.execSync("bun install && bun run script/build.ts --single --skip-install", { + cwd: path.join(ocRepoDir, "packages", "opencode"), + encoding: "utf8", + timeout: 120000, + }); + } catch (err) { + return { ok: false, error: `opencode build failed: ${err}` }; + } + + // Codesign (macOS) + if (process.platform === "darwin") { + const bin = path.join(ocRepoDir, "packages", "opencode", "dist", "opencode-darwin-arm64", "bin", "opencode"); + try { cp.execSync(`codesign --sign - --force "${bin}"`, { timeout: 10000 }); } catch {} + } + + // Build amicode extension + progress("Building extension..."); + try { + cp.execSync("pnpm install && pnpm run build", { + cwd: path.join(repoDir, "packages", "extension"), + encoding: "utf8", + timeout: 120000, + }); + } catch (err) { + return { ok: false, error: `extension build failed: ${err}` }; + } + + return { ok: true }; +} + +// ============================================================================ +// Push local work directly to the host (no GitHub round-trip) +// ============================================================================ + +/** The git remote name used for direct-to-host push. */ +export const FLEET_REMOTE = "fleet-host"; + +/** Ensure the fleet-host remote is configured on local repos pointing at + * the host's repos over SSH. One-time setup per client. */ +export function ensureFleetRemote( + target: string, + opts: { repoDir?: string; ocRepoDir?: string } = {}, +): void { + const repoDir = opts.repoDir ?? detectLocalRepos().amicode; + const ocRepoDir = opts.ocRepoDir ?? detectLocalRepos().opencode; + + for (const dir of [repoDir, ocRepoDir]) { + const repoName = path.basename(dir); // "amicode" or "opencode" + try { + // Check if remote already exists + const existing = cp.execSync(`git remote get-url ${FLEET_REMOTE} 2>/dev/null || true`, { + cwd: dir, encoding: "utf8", timeout: 5000, + }).trim(); + const expectedUrl = `${target}:~/harmoniqs/${repoName}`; + if (!existing || existing !== expectedUrl) { + // Remove stale remote if exists, then add + cp.execSync(`git remote remove ${FLEET_REMOTE} 2>/dev/null || true`, { cwd: dir, timeout: 5000 }); + cp.execSync(`git remote add ${FLEET_REMOTE} ${expectedUrl}`, { cwd: dir, encoding: "utf8", timeout: 5000 }); + } + } catch { + // Best-effort — sync will fail later with a clear error + } + } +} + +/** Configure the host's repos to accept pushes to the checked-out branch. + * Run once during fleet setup. */ +export async function configureHostForDirectPush( + target: string, + opts: { exec?: SshExec } = {}, +): Promise<{ ok: boolean; error?: string }> { + const exec = opts.exec ?? defaultSshExec; + const result = await exec(target, ` + cd ~/harmoniqs/amicode && git config receive.denyCurrentBranch updateInstead + cd ~/harmoniqs/opencode && git config receive.denyCurrentBranch updateInstead + `); + if (!result.ok) return { ok: false, error: result.stderr }; + return { ok: true }; +} + +/** Push any local commits directly to the host's repo over SSH. + * Called on reconnect so the host has the client's standalone work. + * Returns conflict info if the push is rejected (non-fast-forward). */ +export function pushToHost(opts: { onProgress?: (step: string) => void } = {}): { + pushed: boolean; + amicodePushed: boolean; + opencodePushed: boolean; + conflict?: SyncConflict; + error?: string; +} { + const progress = opts.onProgress ?? (() => {}); + const repos = detectLocalRepos(); + const repoDir = repos.amicode; + const ocRepoDir = repos.opencode; + let amicodePushed = false; + let opencodePushed = false; + + // Push amicode if there are commits the host doesn't have + progress("Checking local amicode commits..."); + try { + // Fetch to know what the host has + cp.execSync(`git fetch ${FLEET_REMOTE} 2>/dev/null || true`, { cwd: repoDir, encoding: "utf8", timeout: 15000 }); + const ahead = cp.execSync(`git rev-list --count ${FLEET_REMOTE}/HEAD..HEAD 2>/dev/null || echo 0`, { + cwd: repoDir, encoding: "utf8", timeout: 5000, + }); + if (parseInt(ahead.trim()) > 0) { + progress("Pushing amicode to host..."); + const branch = cp.execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir, encoding: "utf8", timeout: 5000 }).trim(); + try { + cp.execSync(`git push ${FLEET_REMOTE} ${branch}`, { cwd: repoDir, encoding: "utf8", timeout: 30000 }); + amicodePushed = true; + } catch (pushErr) { + const errMsg = String(pushErr); + // Detect non-fast-forward (conflict) + if (errMsg.includes("non-fast-forward") || errMsg.includes("rejected") || errMsg.includes("diverged")) { + const localSha = cp.execSync("git rev-parse HEAD", { cwd: repoDir, encoding: "utf8", timeout: 5000 }).trim(); + const remoteSha = cp.execSync(`git rev-parse ${FLEET_REMOTE}/HEAD 2>/dev/null || echo unknown`, { cwd: repoDir, encoding: "utf8", timeout: 5000 }).trim(); + return { + pushed: false, amicodePushed: false, opencodePushed: false, + conflict: { repo: "amicode", type: "push-rejected", localSha, remoteSha, detail: errMsg }, + }; + } + return { pushed: false, amicodePushed: false, opencodePushed: false, error: `amicode push failed: ${errMsg}` }; + } + } + } catch (err) { + return { pushed: false, amicodePushed: false, opencodePushed: false, error: `amicode sync check failed: ${err}` }; + } + + // Push opencode + progress("Checking local opencode commits..."); + try { + cp.execSync(`git fetch ${FLEET_REMOTE} 2>/dev/null || true`, { cwd: ocRepoDir, encoding: "utf8", timeout: 15000 }); + const ahead = cp.execSync(`git rev-list --count ${FLEET_REMOTE}/HEAD..HEAD 2>/dev/null || echo 0`, { + cwd: ocRepoDir, encoding: "utf8", timeout: 5000, + }); + if (parseInt(ahead.trim()) > 0) { + progress("Pushing opencode to host..."); + const branch = cp.execSync("git rev-parse --abbrev-ref HEAD", { cwd: ocRepoDir, encoding: "utf8", timeout: 5000 }).trim(); + try { + cp.execSync(`git push ${FLEET_REMOTE} ${branch}`, { cwd: ocRepoDir, encoding: "utf8", timeout: 30000 }); + opencodePushed = true; + } catch (pushErr) { + const errMsg = String(pushErr); + if (errMsg.includes("non-fast-forward") || errMsg.includes("rejected") || errMsg.includes("diverged")) { + const localSha = cp.execSync("git rev-parse HEAD", { cwd: ocRepoDir, encoding: "utf8", timeout: 5000 }).trim(); + const remoteSha = cp.execSync(`git rev-parse ${FLEET_REMOTE}/HEAD 2>/dev/null || echo unknown`, { cwd: ocRepoDir, encoding: "utf8", timeout: 5000 }).trim(); + return { + pushed: amicodePushed, amicodePushed, opencodePushed: false, + conflict: { repo: "opencode", type: "push-rejected", localSha, remoteSha, detail: errMsg }, + }; + } + return { pushed: amicodePushed, amicodePushed, opencodePushed: false, error: `opencode push failed: ${errMsg}` }; + } + } + } catch (err) { + return { pushed: amicodePushed, amicodePushed, opencodePushed: false, error: `opencode sync check failed: ${err}` }; + } + + return { pushed: amicodePushed || opencodePushed, amicodePushed, opencodePushed }; +} + +/** Tell the host to rebuild after receiving pushed commits. */ +export async function triggerHostRebuild( + target: string, + opts: { exec?: SshExec; onProgress?: (step: string) => void } = {}, +): Promise<{ ok: boolean; error?: string }> { + const exec = opts.exec ?? defaultSshExec; + const progress = opts.onProgress ?? (() => {}); + + progress("Triggering host rebuild..."); + const result = await exec(target, + "test -x ~/harmoniqs/rebuild_amicode.sh && ~/harmoniqs/rebuild_amicode.sh || (cd ~/harmoniqs/amicode && pnpm install && cd packages/extension && pnpm run build)"); + if (!result.ok) { + return { ok: false, error: result.stderr || "Host rebuild failed" }; + } + return { ok: true }; +} + +// ============================================================================ +// Session sync — host DB replaces client's (host is ground truth in fleet mode) +// ============================================================================ + +/** Overwrite local session DB with the host's. No merge — host wins. */ +export async function syncSessions( + target: string, + opts: { exec?: SshExec; localDir?: string } = {}, +): Promise<{ ok: boolean; error?: string }> { + const exec = opts.exec ?? defaultSshExec; + const localDir = opts.localDir ?? resolveSessionDbDir(); + + // Resolve remote path + const remoteResult = await exec(target, 'echo "${XDG_DATA_HOME:-$HOME/.local/share}/opencode"'); + const remoteDir = remoteResult.ok ? remoteResult.stdout.trim() : "~/.local/share/opencode"; + + fs.mkdirSync(localDir, { recursive: true }); + + // rsync: efficient delta transfer for the large DB. Host → client, overwrite. + try { + cp.execSync( + `rsync -az --timeout=30 -e "ssh -o BatchMode=yes -o ConnectTimeout=10" "${target}:${remoteDir}/opencode.db" "${localDir}/opencode.db"`, + { encoding: "utf8", timeout: 120000 }, + ); + // WAL (may not exist) + try { + cp.execSync( + `rsync -az --timeout=30 -e "ssh -o BatchMode=yes -o ConnectTimeout=10" "${target}:${remoteDir}/opencode.db-wal" "${localDir}/opencode.db-wal"`, + { encoding: "utf8", timeout: 60000 }, + ); + } catch {} + return { ok: true }; + } catch { + // rsync unavailable — fall through to SCP + } + + // Fallback: SCP (full copy, less efficient) + try { + cp.execSync( + `scp -o BatchMode=yes -o ConnectTimeout=10 "${target}:${remoteDir}/opencode.db" "${localDir}/opencode.db"`, + { encoding: "utf8", timeout: 120000 }, + ); + return { ok: true }; + } catch (err) { + return { ok: false, error: `Session sync failed: ${err}` }; + } +} + +// ============================================================================ +// Full sync — the complete reconnect cycle +// ============================================================================ + +/** Full sync. On reconnect from standalone: + * 1. Push local commits directly to host (client's standalone dev work) + * — if conflict detected, returns it for resolution via amicode chat + * 2. Trigger host rebuild (so it picks up the pushed commits) + * 3. Pull the host's state down (host → client, builds + sessions) + * + * In normal fleet mode (no local changes): skips steps 1-2, just pulls. */ +export async function syncFromHost( + target: string, + opts: { + exec?: SshExec; + onProgress?: (step: string) => void; + forceBuild?: boolean; + } = {}, +): Promise { + const exec = opts.exec ?? defaultSshExec; + const progress = opts.onProgress ?? (() => {}); + + // Step 1: Push local work to host (if any) + ensureFleetRemote(target); + const pushResult = pushToHost({ onProgress: progress }); + + // Conflict detected — return it so the caller can launch a resolution session + if (pushResult.conflict) { + progress(`Conflict in ${pushResult.conflict.repo} — needs resolution`); + return { + synced: false, + buildUpdated: false, + sessionsUpdated: false, + conflict: pushResult.conflict, + }; + } + + if (pushResult.error) { + progress(`Push warning: ${pushResult.error}`); + } + + // Step 2: If we pushed, trigger host rebuild so it integrates our changes + if (pushResult.pushed) { + const rebuildResult = await triggerHostRebuild(target, { exec, onProgress: progress }); + if (!rebuildResult.ok) { + progress(`Host rebuild warning: ${rebuildResult.error}`); + } + } + + // Step 3: Check if builds differ and pull + progress("Checking host state..."); + const localSha = getLocalSha(); + const remoteSha = await getRemoteSha(target, { exec }); + const buildsDiffer = !localSha || !remoteSha || localSha !== remoteSha; + + let buildUpdated = false; + if (buildsDiffer || opts.forceBuild) { + const buildResult = await syncBuild(target, { exec, onProgress: progress }); + if (!buildResult.ok) { + return { synced: false, buildUpdated: false, sessionsUpdated: false, error: buildResult.error }; + } + buildUpdated = true; + } + + // Step 4: Sessions always sync (host → client, overwrite) + progress("Syncing sessions..."); + const sessionResult = await syncSessions(target, { exec }); + + progress(buildUpdated ? "Synced — reload window to pick up new build" : "Up to date"); + return { synced: true, buildUpdated, sessionsUpdated: sessionResult.ok, error: sessionResult.error }; +} + +// ============================================================================ +// Auto-sync trigger +// ============================================================================ + +/** Should auto-sync run? (client mode + host configured) */ +export function shouldAutoSync(): { should: boolean; target?: string } { + if (getFleetRole() !== "client") return { should: false }; + const config = readFleetConfig(); + const target = config?.canonical?.sshAlias ?? config?.canonical?.host; + if (!target) return { should: false }; + return { should: true, target }; +} + +// ============================================================================ +// Conflict resolution — launches an amicode chat to resolve sync conflicts +// ============================================================================ + +/** Build the initial prompt for a conflict-resolution amicode session. */ +export function buildConflictResolutionPrompt(conflict: SyncConflict): string { + const repoDir = conflict.repo === "amicode" + ? "~/harmoniqs/amicode" + : "~/harmoniqs/opencode"; + + return `Fleet sync conflict detected in **${conflict.repo}** (${repoDir}). + +## What happened + +The local branch has diverged from the fleet host. The push to the host was rejected because both sides have commits the other doesn't. + +- **Local HEAD**: \`${conflict.localSha}\` +- **Host HEAD**: \`${conflict.remoteSha}\` +- **Type**: ${conflict.type} + +## What needs to happen + +Resolve the divergence so both client and host are on the same commit. Options: + +1. **Rebase local onto host** — \`git fetch fleet-host && git rebase fleet-host/HEAD\` — preserves local work on top of the host's state. Resolve any file conflicts, then push. +2. **Force-push local** — \`git push fleet-host --force-with-lease\` — overwrites the host with local state. Only if you're sure the host's divergent commits are disposable. +3. **Reset local to host** — \`git reset --hard fleet-host/HEAD\` — discards local work, mirrors the host exactly. Use if the local changes are expendable. + +Please inspect the divergence (\`git log --oneline fleet-host/HEAD..HEAD\` for local-only commits, \`git log --oneline HEAD..fleet-host/HEAD\` for host-only commits) and resolve it. After resolution, re-run the fleet sync.`; +} + + diff --git a/packages/extension/src/fleet_topology_data.ts b/packages/extension/src/fleet_topology_data.ts new file mode 100644 index 00000000..6d14fb28 --- /dev/null +++ b/packages/extension/src/fleet_topology_data.ts @@ -0,0 +1,75 @@ +// Fleet topology data — pure functions that build FleetNode[] + FleetEdge[] +// from the fleet config and health probes. Used by the fleet panel host to +// push topology updates to the webview. +// +// Part of #353 (Fleet Panel: SVG topology graph with clickable nodes). + +import * as os from "node:os"; +import { readFleetConfig, type FleetConfig } from "./fleet_fallback"; +import type { FleetNode, FleetEdge } from "./fleet_panel"; + +/** Build the topology graph data from the current fleet configuration. + * This is a pure projection from config → graph nodes + edges. */ +export function buildTopologyFromConfig( + config: FleetConfig | null, + opts: { + localHostname?: string; + serverHealthy?: boolean; + clientsHealthy?: Map; + sessionCounts?: Map; + } = {}, +): { nodes: FleetNode[]; edges: FleetEdge[] } { + const localHostname = opts.localHostname ?? os.hostname(); + const role = config?.role ?? "standalone"; + + if (role === "standalone" || !config) { + return { nodes: [], edges: [] }; + } + + const nodes: FleetNode[] = []; + const edges: FleetEdge[] = []; + const serverHost = config.canonical?.host ?? "server"; + const serverHealthy = opts.serverHealthy ?? true; + + // Server node + const serverId = "server"; + nodes.push({ + id: serverId, + hostname: serverHost, + role: "server", + isLocal: role === "server", + healthy: serverHealthy, + sessionCount: opts.sessionCounts?.get(serverId) ?? 0, + }); + + // Local client node (if this machine is a client) + if (role === "client") { + const clientId = `client-${localHostname}`; + nodes.push({ + id: clientId, + hostname: localHostname, + role: "client", + isLocal: true, + healthy: true, // local machine is always "healthy" from its own perspective + sessionCount: opts.sessionCounts?.get(clientId) ?? 0, + }); + edges.push({ + from: serverId, + to: clientId, + connected: serverHealthy, + }); + } + + return { nodes, edges }; +} + +/** Read the current fleet config and build topology data. + * Convenience wrapper around buildTopologyFromConfig. */ +export function readTopology(opts?: { + serverHealthy?: boolean; +}): { nodes: FleetNode[]; edges: FleetEdge[] } { + const config = readFleetConfig(); + return buildTopologyFromConfig(config, { + serverHealthy: opts?.serverHealthy, + }); +} diff --git a/packages/extension/src/fleet_webview.ts b/packages/extension/src/fleet_webview.ts new file mode 100644 index 00000000..6fc089b7 --- /dev/null +++ b/packages/extension/src/fleet_webview.ts @@ -0,0 +1,20 @@ +// Fleet Panel webview entry — mounts the TS-composed view (media/ui/views/ +// fleet.ts). No static markup: the view builds its own DOM from atoms/components; +// brand.css + layout.css are linked by the shell (fleet_panel.ts). +// Mirrors device_inspector_webview.ts. + +import { applyBrandAccent } from "../media/ui/brand_accent"; +import { createFleetView } from "../media/ui/views/fleet"; + +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + +declare function acquireVsCodeApi(): { + postMessage(msg: unknown): void; +}; + +const vscodeApi = acquireVsCodeApi(); +const view = createFleetView((msg) => vscodeApi.postMessage(msg)); +document.body.append(view.el); +window.addEventListener("message", (e) => view.onMessage(e.data)); + +vscodeApi.postMessage({ type: "log", text: "fleet_webview booted" }); diff --git a/packages/extension/src/status_bar.ts b/packages/extension/src/status_bar.ts index 4cb1efb1..95933a1d 100644 --- a/packages/extension/src/status_bar.ts +++ b/packages/extension/src/status_bar.ts @@ -38,10 +38,25 @@ export function statusBarLabel(serverReady: boolean, run?: RunState): { text: st } } +/** Pure label/tooltip for the fleet status-bar item. */ +export function fleetStatusBarLabel(role: "standalone" | "server" | "client", hostInfo?: string): { text: string; tooltip: string; command: string } { + switch (role) { + case "server": + return { text: "$(cloud) Fleet: server", tooltip: "This machine is the canonical server — click to open Fleet panel", command: "amicode.fleetPanel.focus" }; + case "client": + return { text: "$(cloud) Fleet: client", tooltip: `Fleet client → ${hostInfo ?? "remote"} — click to go standalone`, command: "amicode.fleet.goStandalone" }; + default: + return { text: "$(cloud) Fleet: standalone", tooltip: "No fleet configured — click to open Fleet panel", command: "amicode.fleetPanel.focus" }; + } +} + export class StatusBarManager { private readonly item: vscode.StatusBarItem; + private readonly fleetItem: vscode.StatusBarItem; private serverReady = false; private run?: RunState; + private fleetRole: "standalone" | "server" | "client" = "standalone"; + private fleetHostInfo?: string; constructor() { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); @@ -49,6 +64,11 @@ export class StatusBarManager { this.item.command = "amicode.openInspector"; this.item.show(); this.render(); + + // Fleet status bar item — always visible, command depends on role. + this.fleetItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); + this.fleetItem.show(); + this.renderFleet(); } setServerReady(ready: boolean): void { @@ -61,8 +81,15 @@ export class StatusBarManager { this.render(); } + setFleetRole(role: "standalone" | "server" | "client", hostInfo?: string): void { + this.fleetRole = role; + this.fleetHostInfo = hostInfo; + this.renderFleet(); + } + dispose(): void { this.item.dispose(); + this.fleetItem.dispose(); } private render(): void { @@ -70,4 +97,11 @@ export class StatusBarManager { this.item.text = text; this.item.tooltip = tooltip; } + + private renderFleet(): void { + const { text, tooltip, command } = fleetStatusBarLabel(this.fleetRole, this.fleetHostInfo); + this.fleetItem.text = text; + this.fleetItem.tooltip = tooltip; + this.fleetItem.command = command; + } } diff --git a/packages/extension/test/__mocks__/vscode.ts b/packages/extension/test/__mocks__/vscode.ts index b9b16e52..a561297e 100644 --- a/packages/extension/test/__mocks__/vscode.ts +++ b/packages/extension/test/__mocks__/vscode.ts @@ -17,12 +17,15 @@ export const window = { onDidChangeActiveColorTheme: (_cb: unknown, _thisArg?: unknown, _subs?: unknown) => ({ dispose() {} }), createWebviewPanel: (_viewType: string, _title: string, _column?: unknown, _opts?: unknown) => { const disposeCbs: Array<() => void> = []; + const posted: unknown[] = []; return { webview: { html: "", cspSource: "test:", asWebviewUri: (u: unknown) => u, onDidReceiveMessage: () => ({ dispose() {} }), + postMessage: (msg: unknown) => { posted.push(msg); return Promise.resolve(true); }, + posted, }, revealCount: 0, reveal() { diff --git a/packages/extension/test/fleet_bridge.test.ts b/packages/extension/test/fleet_bridge.test.ts new file mode 100644 index 00000000..9740b7c1 --- /dev/null +++ b/packages/extension/test/fleet_bridge.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + buildFleetStateSnapshot, + parseFleetAction, + enqueueFleetSignal, + FLEET_BRIDGE_KINDS, + type FleetAction, +} from "../src/fleet_bridge"; + +describe("buildFleetStateSnapshot", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-bridge-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("builds snapshot from TOML records", () => { + fs.writeFileSync( + path.join(tmp, "ses_abc.toml"), + `state = "running"\ntokens = 42\nhost = "my-server"\ncurrent_step = "solving CZ gate"\nname = "researcher-opus"\n`, + ); + const snapshot = buildFleetStateSnapshot(tmp); + expect(snapshot.type).toBe("fleet-state"); + expect(snapshot.payload.sessions).toHaveLength(1); + expect(snapshot.payload.sessions[0].session_id).toBe("ses_abc"); + expect(snapshot.payload.sessions[0].state).toBe("running"); + expect(snapshot.payload.sessions[0].tokens).toBe(42); + expect(snapshot.payload.sessions[0].host).toBe("my-server"); + expect(snapshot.payload.sessions[0].current_step).toBe("solving CZ gate"); + expect(snapshot.payload.sessions[0].profile_name).toBe("researcher-opus"); + }); + + it("returns empty sessions for missing directory", () => { + const snapshot = buildFleetStateSnapshot("/nonexistent"); + expect(snapshot.payload.sessions).toEqual([]); + }); + + it("skips dot-files", () => { + fs.writeFileSync(path.join(tmp, ".tmp.toml"), `state = "running"\ntokens = 1\n`); + const snapshot = buildFleetStateSnapshot(tmp); + expect(snapshot.payload.sessions).toHaveLength(0); + }); +}); + +describe("parseFleetAction", () => { + it("parses a valid fleet-action message", () => { + const action = parseFleetAction({ + type: "fleet-action", + verb: "stop", + session_id: "ses_abc123", + params: {}, + }); + expect(action).not.toBeNull(); + expect(action!.verb).toBe("stop"); + expect(action!.session_id).toBe("ses_abc123"); + }); + + it("accepts all valid verbs", () => { + for (const verb of ["steer", "stop", "re-tier", "view-details"]) { + const action = parseFleetAction({ type: "fleet-action", verb, session_id: "ses_x", params: {} }); + expect(action).not.toBeNull(); + expect(action!.verb).toBe(verb); + } + }); + + it("rejects invalid verb", () => { + expect(parseFleetAction({ type: "fleet-action", verb: "explode", session_id: "ses_x" })).toBeNull(); + }); + + it("rejects missing session_id", () => { + expect(parseFleetAction({ type: "fleet-action", verb: "stop", session_id: "" })).toBeNull(); + }); + + it("rejects wrong type", () => { + expect(parseFleetAction({ type: "not-fleet", verb: "stop", session_id: "ses_x" })).toBeNull(); + }); + + it("rejects non-object", () => { + expect(parseFleetAction(null)).toBeNull(); + expect(parseFleetAction("string")).toBeNull(); + expect(parseFleetAction(42)).toBeNull(); + }); +}); + +describe("enqueueFleetSignal", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-signal-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("creates a signal file in the session signal directory", () => { + const action: FleetAction = { + type: "fleet-action", + verb: "stop", + session_id: "ses_abc123", + params: {}, + }; + const signalPath = enqueueFleetSignal(action, tmp); + expect(fs.existsSync(signalPath)).toBe(true); + expect(signalPath).toContain("ses_abc123.signal.d"); + expect(signalPath).toContain("stop-"); + const content = JSON.parse(fs.readFileSync(signalPath, "utf8")); + expect(content.verb).toBe("stop"); + expect(content.ts).toBeTruthy(); + }); + + it("creates the signal directory if absent", () => { + const action: FleetAction = { + type: "fleet-action", + verb: "steer", + session_id: "ses_new", + params: { instruction: "focus on fidelity" }, + }; + enqueueFleetSignal(action, tmp); + expect(fs.existsSync(path.join(tmp, "ses_new.signal.d"))).toBe(true); + }); +}); + +describe("FLEET_BRIDGE_KINDS", () => { + it("declares inbound and outbound message types", () => { + expect(FLEET_BRIDGE_KINDS.inbound).toBe("fleet-state"); + expect(FLEET_BRIDGE_KINDS.outbound).toBe("fleet-action"); + }); +}); diff --git a/packages/extension/test/fleet_compat.test.ts b/packages/extension/test/fleet_compat.test.ts new file mode 100644 index 00000000..f56ac83a --- /dev/null +++ b/packages/extension/test/fleet_compat.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { + parseSemVer, + checkCompatibility, + buildServerVersionFile, + KNOWN_CAPABILITIES, + CLIENT_VERSION, + type ServerInfo, +} from "../src/fleet_compat"; + +describe("parseSemVer", () => { + it("parses a valid semver string", () => { + expect(parseSemVer("0.2.1")).toEqual({ major: 0, minor: 2, patch: 1 }); + expect(parseSemVer("1.0.0")).toEqual({ major: 1, minor: 0, patch: 0 }); + expect(parseSemVer("2.15.3")).toEqual({ major: 2, minor: 15, patch: 3 }); + }); + + it("handles version with extra suffix", () => { + expect(parseSemVer("0.2.1-beta.1")).toEqual({ major: 0, minor: 2, patch: 1 }); + }); + + it("returns null for invalid", () => { + expect(parseSemVer("abc")).toBeNull(); + expect(parseSemVer("")).toBeNull(); + expect(parseSemVer("1.2")).toBeNull(); + }); +}); + +describe("checkCompatibility", () => { + const baseServer: ServerInfo = { + version: "0.2.1", + schema: 1, + capabilities: ["sessions", "fleet-state", "fleet-action"], + }; + + it("returns compatible when versions match", () => { + const result = checkCompatibility("0.2.1", baseServer); + expect(result.state).toBe("compatible"); + expect(result.missingCapabilities).toEqual([]); + }); + + it("returns compatible when client is newer (same major)", () => { + const result = checkCompatibility("0.3.0", { ...baseServer, version: "0.2.1" }); + expect(result.state).toBe("compatible"); + }); + + it("returns degraded when server is newer (same major, higher minor)", () => { + const result = checkCompatibility("0.2.1", { ...baseServer, version: "0.3.0" }); + expect(result.state).toBe("degraded"); + expect(result.message).toContain("rebuild extension"); + }); + + it("returns degraded when server has unknown capabilities", () => { + const serverWithNew: ServerInfo = { + version: "0.2.1", + schema: 1, + capabilities: ["sessions", "fleet-state", "new-future-feature"], + }; + const result = checkCompatibility("0.2.1", serverWithNew); + expect(result.state).toBe("degraded"); + expect(result.missingCapabilities).toContain("new-future-feature"); + }); + + it("returns incompatible on major version mismatch", () => { + const result = checkCompatibility("0.2.1", { ...baseServer, version: "1.0.0" }); + expect(result.state).toBe("incompatible"); + expect(result.message).toContain("Go Standalone"); + }); + + it("returns degraded when versions can't be parsed", () => { + const result = checkCompatibility("bad", { ...baseServer, version: "also-bad" }); + expect(result.state).toBe("degraded"); + expect(result.message).toContain("Could not parse"); + }); +}); + +describe("buildServerVersionFile", () => { + it("produces valid JSON with version, schema, and capabilities", () => { + const file = buildServerVersionFile("0.2.1"); + const parsed = JSON.parse(file); + expect(parsed.version).toBe("0.2.1"); + expect(parsed.schema).toBe(1); + expect(parsed.capabilities).toEqual([...KNOWN_CAPABILITIES]); + }); + + it("allows custom capabilities", () => { + const file = buildServerVersionFile("1.0.0", ["sessions", "custom"]); + const parsed = JSON.parse(file); + expect(parsed.capabilities).toEqual(["sessions", "custom"]); + }); +}); + +describe("CLIENT_VERSION", () => { + it("is a valid semver string", () => { + expect(parseSemVer(CLIENT_VERSION)).not.toBeNull(); + }); +}); diff --git a/packages/extension/test/fleet_fallback.test.ts b/packages/extension/test/fleet_fallback.test.ts index e28d24a5..2b954bf8 100644 --- a/packages/extension/test/fleet_fallback.test.ts +++ b/packages/extension/test/fleet_fallback.test.ts @@ -51,4 +51,21 @@ describe("fleet_fallback (fleet config)", () => { expect(cfg.previousBinary).toBe("/old/bin"); expect(cfg.previousPort).toBe(4096); }); + + it("goStandalone preserves canonical coordinates from existing config", () => { + // Pre-existing fleet config with canonical + writeFleetConfig({ role: "client", canonical: { host: "mac-studio", port: 4096, sshAlias: "studio" } }, p); + + // Go standalone — should keep canonical for reconnect + const cfg = goStandalone({ path: p, previousBinary: "/bin/oc", previousPort: 4096 }); + expect(cfg.role).toBe("standalone"); + expect(cfg.canonical?.host).toBe("mac-studio"); + expect(cfg.canonical?.port).toBe(4096); + expect(cfg.canonical?.sshAlias).toBe("studio"); + + // Verify it persisted to disk + const read = readFleetConfig(p); + expect(read?.role).toBe("standalone"); + expect(read?.canonical?.host).toBe("mac-studio"); + }); }); diff --git a/packages/extension/test/fleet_health.test.ts b/packages/extension/test/fleet_health.test.ts index d1b8223a..fe94c598 100644 --- a/packages/extension/test/fleet_health.test.ts +++ b/packages/extension/test/fleet_health.test.ts @@ -48,13 +48,12 @@ describe("fleet_health", () => { expect(c.detail).toMatch(/not set/); }); - it("settings: fails when port wrong", () => { + it("settings: port is not checked (user's standalone preference, irrelevant in client mode)", () => { const c = checkFleetSettings(INSTALLED, 43117, { platform: "darwin", fleetConfig: { role: "client", canonical: { port: 4096 } } }); - expect(c.ok).toBe(false); - expect(c.detail).toMatch(/43117/); + expect(c.ok).toBe(true); }); - it("settings: ok when guard + matching port", () => { + it("settings: ok when guard set (port ignored)", () => { const c = checkFleetSettings(INSTALLED, 4096, { platform: "darwin", fleetConfig: { role: "client", canonical: { port: 4096 } } }); expect(c.ok).toBe(true); }); diff --git a/packages/extension/test/fleet_host_settings.test.ts b/packages/extension/test/fleet_host_settings.test.ts new file mode 100644 index 00000000..ed922c8b --- /dev/null +++ b/packages/extension/test/fleet_host_settings.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + validateHostSettings, + settingsNeedingRestart, + RESTART_REQUIRED_SETTINGS, + type HostSettings, +} from "../src/fleet_host_settings"; + +const VALID: HostSettings = { + dbPath: "/home/user/.amico/ops/sessions.db", + port: 4096, + binaryPath: "/usr/local/bin/opencode", + logDir: "/var/log/amico", +}; + +describe("validateHostSettings", () => { + it("passes for valid settings", () => { + expect(validateHostSettings(VALID)).toEqual([]); + }); + + it("rejects port below 1024", () => { + const errors = validateHostSettings({ ...VALID, port: 80 }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("Port must be"); + }); + + it("rejects port above 65535", () => { + const errors = validateHostSettings({ ...VALID, port: 70000 }); + expect(errors).toHaveLength(1); + }); + + it("rejects non-integer port", () => { + const errors = validateHostSettings({ ...VALID, port: 4096.5 }); + expect(errors).toHaveLength(1); + }); + + it("rejects relative dbPath", () => { + const errors = validateHostSettings({ ...VALID, dbPath: "relative/path" }); + expect(errors).toContain("Database path must be absolute"); + }); + + it("rejects relative binaryPath", () => { + const errors = validateHostSettings({ ...VALID, binaryPath: "opencode" }); + expect(errors).toContain("Binary path must be absolute"); + }); + + it("rejects relative logDir", () => { + const errors = validateHostSettings({ ...VALID, logDir: "logs/" }); + expect(errors).toContain("Log directory must be absolute"); + }); + + it("allows undefined fields (partial validation)", () => { + expect(validateHostSettings({})).toEqual([]); + }); +}); + +describe("settingsNeedingRestart", () => { + it("returns empty when nothing changed", () => { + expect(settingsNeedingRestart(VALID, VALID)).toEqual([]); + }); + + it("detects port change", () => { + const updated = { ...VALID, port: 5000 }; + const needs = settingsNeedingRestart(VALID, updated); + expect(needs).toContain("port"); + }); + + it("detects binaryPath change", () => { + const updated = { ...VALID, binaryPath: "/new/path/opencode" }; + const needs = settingsNeedingRestart(VALID, updated); + expect(needs).toContain("binaryPath"); + }); + + it("detects dbPath change", () => { + const updated = { ...VALID, dbPath: "/new/db/path" }; + const needs = settingsNeedingRestart(VALID, updated); + expect(needs).toContain("dbPath"); + }); + + it("does NOT require restart for logDir change", () => { + const updated = { ...VALID, logDir: "/different/logs" }; + const needs = settingsNeedingRestart(VALID, updated); + expect(needs).not.toContain("logDir"); + }); +}); + +describe("RESTART_REQUIRED_SETTINGS", () => { + it("includes dbPath, port, binaryPath but not logDir", () => { + expect(RESTART_REQUIRED_SETTINGS).toContain("dbPath"); + expect(RESTART_REQUIRED_SETTINGS).toContain("port"); + expect(RESTART_REQUIRED_SETTINGS).toContain("binaryPath"); + expect(RESTART_REQUIRED_SETTINGS).not.toContain("logDir"); + }); +}); diff --git a/packages/extension/test/fleet_launch.test.ts b/packages/extension/test/fleet_launch.test.ts new file mode 100644 index 00000000..e0a02e64 --- /dev/null +++ b/packages/extension/test/fleet_launch.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + generateSessionId, + buildLaunchRecord, + writeFleetRecord, + launchFromProfile, + computeFleetStats, + readAllSimpleRecords, + sweepCrashed, + isProcessAlive, +} from "../src/fleet_launch"; +import type { FleetProfile } from "../src/fleet_profiles"; + +const SAMPLE_PROFILE: FleetProfile = { + schema: 1, + name: "researcher-opus", + base: "pulse-designer", + model: "anthropic.claude-opus-4-6-v1", + variant: "", + task_type: "interactive", + skills: ["transmon", "atoms"], + gates: [], + permissions: { bash: "allow", file_write: "allow" }, +}; + +describe("generateSessionId", () => { + it("produces ses_ prefix + 12 hex chars", () => { + const id = generateSessionId(); + expect(id).toMatch(/^ses_[0-9a-f]{12}$/); + }); + + it("generates unique IDs", () => { + const ids = new Set(Array.from({ length: 100 }, () => generateSessionId())); + expect(ids.size).toBe(100); + }); +}); + +describe("buildLaunchRecord", () => { + it("creates a record in spooling state from a profile", () => { + const record = buildLaunchRecord(SAMPLE_PROFILE); + expect(record.state).toBe("spooling"); + expect(record.session_id).toMatch(/^ses_/); + expect(record.tokens).toBe(0); + expect(record.runtime).toBe(0); + expect(record.profile.name).toBe("researcher-opus"); + expect(record.profile.model).toBe("anthropic.claude-opus-4-6-v1"); + expect(record.profile.skills).toEqual(["transmon", "atoms"]); + expect(record.pid).toBe(process.pid); + expect(record.started).toBeTruthy(); + }); +}); + +describe("writeFleetRecord + launchFromProfile", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-launch-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("writes a TOML file with the session_id as filename", () => { + const record = buildLaunchRecord(SAMPLE_PROFILE); + const p = writeFleetRecord(record, tmp); + expect(fs.existsSync(p)).toBe(true); + expect(path.basename(p)).toBe(`${record.session_id}.toml`); + const content = fs.readFileSync(p, "utf8"); + expect(content).toContain("spooling"); + expect(content).toContain("researcher-opus"); + }); + + it("launchFromProfile returns sessionId and recordPath", () => { + const result = launchFromProfile(SAMPLE_PROFILE, tmp); + expect(result.sessionId).toMatch(/^ses_/); + expect(fs.existsSync(result.recordPath)).toBe(true); + }); +}); + +describe("computeFleetStats", () => { + it("counts active (running + blocked + spooling)", () => { + const records = [ + { state: "running", tokens: 100, started: "2026-08-13T10:00:00Z" }, + { state: "running", tokens: 200, started: "2026-08-13T11:00:00Z" }, + { state: "blocked", tokens: 50, started: "2026-08-13T09:00:00Z" }, + { state: "spooling", tokens: 0, started: "2026-08-13T12:00:00Z" }, + { state: "settled", tokens: 500, started: "2026-08-13T08:00:00Z" }, + { state: "crashed", tokens: 30, started: "2026-08-12T10:00:00Z" }, + ]; + const stats = computeFleetStats(records, { today: "2026-08-13" }); + expect(stats.active).toBe(4); // running + blocked + spooling + expect(stats.running).toBe(2); + expect(stats.blocked).toBe(1); + expect(stats.tokensToday).toBe(850); // 100 + 200 + 50 + 0 + 500 (all started today) + }); + + it("returns zeros for empty records", () => { + const stats = computeFleetStats([]); + expect(stats.active).toBe(0); + expect(stats.running).toBe(0); + expect(stats.blocked).toBe(0); + expect(stats.tokensToday).toBe(0); + }); +}); + +describe("readAllSimpleRecords", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-stats-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("reads state + tokens from TOML files", () => { + fs.writeFileSync(path.join(tmp, "ses_abc.toml"), `state = "running"\ntokens = 42\nstarted = "2026-08-13T10:00:00Z"\n`); + fs.writeFileSync(path.join(tmp, "ses_def.toml"), `state = "settled"\ntokens = 100\nstarted = "2026-08-13T11:00:00Z"\n`); + const records = readAllSimpleRecords(tmp); + expect(records).toHaveLength(2); + expect(records.find((r) => r.state === "running")?.tokens).toBe(42); + }); + + it("skips dot-files", () => { + fs.writeFileSync(path.join(tmp, ".tmp.toml"), `state = "running"\ntokens = 1\n`); + expect(readAllSimpleRecords(tmp)).toHaveLength(0); + }); + + it("returns empty for missing directory", () => { + expect(readAllSimpleRecords("/nonexistent/path")).toEqual([]); + }); +}); + +describe("sweepCrashed", () => { + let tmp: string; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-sweep-")); + }); + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("marks running sessions with dead pids as crashed", () => { + // Use a pid that definitely doesn't exist + const deadPid = 99999999; + fs.writeFileSync( + path.join(tmp, "ses_dead.toml"), + `state = "running"\npid = ${deadPid}\ntokens = 10\nstarted = "2026-08-13"\n`, + ); + const swept = sweepCrashed(tmp); + expect(swept).toContain("ses_dead"); + const content = fs.readFileSync(path.join(tmp, "ses_dead.toml"), "utf8"); + expect(content).toContain('state = "crashed"'); + }); + + it("does not touch settled sessions", () => { + fs.writeFileSync( + path.join(tmp, "ses_ok.toml"), + `state = "settled"\npid = 99999999\ntokens = 10\nstarted = "2026-08-13"\n`, + ); + const swept = sweepCrashed(tmp); + expect(swept).toHaveLength(0); + }); + + it("does not touch sessions with alive pids", () => { + // Current process is alive + fs.writeFileSync( + path.join(tmp, "ses_alive.toml"), + `state = "running"\npid = ${process.pid}\ntokens = 10\nstarted = "2026-08-13"\n`, + ); + const swept = sweepCrashed(tmp); + expect(swept).toHaveLength(0); + }); +}); + +describe("isProcessAlive", () => { + it("returns true for current process", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + it("returns false for pid 0 or negative", () => { + expect(isProcessAlive(0)).toBe(false); + expect(isProcessAlive(-1)).toBe(false); + }); +}); diff --git a/packages/extension/test/fleet_panel.test.ts b/packages/extension/test/fleet_panel.test.ts new file mode 100644 index 00000000..01c33bc4 --- /dev/null +++ b/packages/extension/test/fleet_panel.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { FleetPanelView, registerFleetPanel } from "../src/fleet_panel"; +import type { FleetHostMessage, FleetWebviewMessage } from "../src/fleet_panel"; +import { fleetStatusBarLabel } from "../src/status_bar"; +import { commands } from "vscode"; +import { ChatPanel } from "../src/chat_panel"; + +// ── Host provider tests ───────────────────────────────────────────────────── + +describe("FleetPanelView", () => { + it("can be instantiated with a mock context", () => { + const ctx = { + extensionUri: { fsPath: "/mock/ext" }, + subscriptions: [], + }; + const panel = new FleetPanelView(ctx as any); + expect(panel).toBeDefined(); + }); +}); + +describe("registerFleetPanel", () => { + it("registers the webview view provider and returns the panel", () => { + const ctx = { + extensionUri: { fsPath: "/mock/ext" }, + subscriptions: [] as any[], + }; + const panel = registerFleetPanel(ctx as any); + expect(panel).toBeDefined(); + expect(panel).toBeInstanceOf(FleetPanelView); + }); +}); + +// ── postMessage protocol tests ────────────────────────────────────────────── + +describe("FleetPanelView postMessage protocol", () => { + let panel: FleetPanelView; + let postedMessages: FleetHostMessage[]; + let receivedByHost: FleetWebviewMessage[]; + + beforeEach(() => { + postedMessages = []; + receivedByHost = []; + + const ctx = { + extensionUri: { fsPath: "/mock/ext" }, + subscriptions: [] as any[], + }; + panel = new FleetPanelView(ctx as any); + + // Simulate resolveWebviewView with a mock view + const mockWebview = { + options: {}, + html: "", + cspSource: "test:", + asWebviewUri: (u: any) => u, + postMessage: (msg: FleetHostMessage) => { + postedMessages.push(msg); + return Promise.resolve(true); + }, + onDidReceiveMessage: (handler: (msg: FleetWebviewMessage) => void) => { + // Store handler so we can simulate webview messages + (panel as any)._testMsgHandler = handler; + return { dispose: () => {} }; + }, + }; + const mockView = { + webview: mockWebview, + onDidDispose: (_cb: () => void) => ({ dispose: () => {} }), + }; + panel.resolveWebviewView(mockView as any); + }); + + it("pushes role message on resolveWebviewView", () => { + // resolveWebviewView already called in beforeEach → pushRole fires + expect(postedMessages.some((m) => m.type === "role")).toBe(true); + }); + + it("postTopology sends topology message to webview", () => { + postedMessages.length = 0; + panel.postTopology( + [{ id: "srv", hostname: "remote-server", role: "server", isLocal: false, healthy: true, sessionCount: 2 }], + [{ from: "srv", to: "local", connected: true }], + ); + expect(postedMessages).toHaveLength(1); + expect(postedMessages[0].type).toBe("topology"); + }); + + it("postProfiles sends profiles message to webview", () => { + postedMessages.length = 0; + panel.postProfiles([{ slug: "test", name: "Test Profile", model: "claude-opus", variant: "" }]); + expect(postedMessages).toHaveLength(1); + expect(postedMessages[0].type).toBe("profiles"); + }); + + it("postStats sends stats message to webview", () => { + postedMessages.length = 0; + panel.postStats({ active: 3, running: 2, blocked: 1, tokensToday: 42000 }); + expect(postedMessages).toHaveLength(1); + expect(postedMessages[0].type).toBe("stats"); + if (postedMessages[0].type === "stats") { + expect(postedMessages[0].stats.active).toBe(3); + } + }); + + it("webview action message dispatches as a VS Code command", () => { + // Simulate webview posting an action + const handler = (panel as any)._testMsgHandler; + expect(handler).toBeDefined(); + + // Reset the executed commands tracker from the mock + (commands as any).executed.length = 0; + + handler({ type: "action", action: "createFleet" }); + + // The panel should dispatch via executeCommand + expect((commands as any).executed).toContain("amicode.fleet.createFleet"); + }); + + it("webview action message passes payload to the command", () => { + const handler = (panel as any)._testMsgHandler; + expect(handler).toBeDefined(); + + (commands as any).executed.length = 0; + + handler({ type: "action", action: "removeMachine", payload: { target: "jj@100.77.141.50" } }); + + expect((commands as any).executed).toContain("amicode.fleet.removeMachine"); + }); +}); + +// ── Rendered HTML tests ───────────────────────────────────────────────────── + +describe("FleetPanelView renderHtml", () => { + it("produces HTML with CSP nonce and correct script/stylesheet references", () => { + const ctx = { + extensionUri: { fsPath: "/mock/ext" }, + subscriptions: [] as any[], + }; + const panel = new FleetPanelView(ctx as any); + + let capturedHtml = ""; + const mockWebview = { + options: {}, + html: "", + cspSource: "https://test.csp", + asWebviewUri: (u: any) => `webview-uri:${typeof u === "string" ? u : JSON.stringify(u)}`, + postMessage: () => Promise.resolve(true), + onDidReceiveMessage: () => ({ dispose: () => {} }), + }; + const mockView = { + webview: mockWebview, + onDidDispose: () => ({ dispose: () => {} }), + }; + + panel.resolveWebviewView(mockView as any); + capturedHtml = mockWebview.html; + + // Should have a nonce in the CSP + expect(capturedHtml).toContain("nonce-"); + // Should reference brand.css and layout.css + expect(capturedHtml).toContain("brand.css"); + expect(capturedHtml).toContain("layout.css"); + // Should load fleet_webview.js + expect(capturedHtml).toContain("fleet_webview.js"); + // Should have Content-Security-Policy + expect(capturedHtml).toContain("Content-Security-Policy"); + }); +}); + +// ── Fleet status bar label tests ──────────────────────────────────────────── + +describe("fleetStatusBarLabel", () => { + it("returns standalone label for standalone role", () => { + const { text, tooltip, command } = fleetStatusBarLabel("standalone"); + expect(text).toContain("Fleet: standalone"); + expect(tooltip).toContain("No fleet configured"); + expect(command).toBe("amicode.fleetPanel.focus"); + }); + + it("returns server label for server role", () => { + const { text, tooltip, command } = fleetStatusBarLabel("server"); + expect(text).toContain("Fleet: server"); + expect(tooltip).toContain("canonical server"); + expect(command).toBe("amicode.fleetPanel.focus"); + }); + + it("returns client label for client role", () => { + const { text, tooltip, command } = fleetStatusBarLabel("client"); + expect(text).toContain("Fleet: client"); + expect(tooltip).toContain("go standalone"); + expect(command).toBe("amicode.fleet.goStandalone"); + }); + + it("includes host info in client tooltip when provided", () => { + const { tooltip } = fleetStatusBarLabel("client", "jj@100.77.141.50:4096"); + expect(tooltip).toContain("jj@100.77.141.50:4096"); + }); +}); + +// ── ChatPanel.navigateToPath end-to-end test ──────────────────────────────── + +describe("ChatPanel.navigateToPath", () => { + it("posts a navigate message with the correct path to the webview", () => { + const ctx = { + extensionUri: { fsPath: "/mock/ext" }, + subscriptions: [] as any[], + }; + + const panel = ChatPanel.openOrReveal( + ctx as any, + new URL("http://127.0.0.1:4096"), + "token", + "/mock/project", + ); + + panel.navigateToPath("/new-session?prompt=I+want+to+create+a+fleet"); + + // Get the underlying webview panel's posted messages + const webview = (panel as any).panel.webview; + const msgs = webview.posted.filter((m: any) => m?.kind === "navigate"); + expect(msgs.length).toBeGreaterThanOrEqual(1); + expect(msgs[0]).toEqual({ + source: "amicode", + kind: "navigate", + path: "/new-session?prompt=I+want+to+create+a+fleet", + }); + + // Clean up + (panel as any).panel.dispose(); + }); +}); diff --git a/packages/extension/test/fleet_profiles.test.ts b/packages/extension/test/fleet_profiles.test.ts new file mode 100644 index 00000000..a4087e68 --- /dev/null +++ b/packages/extension/test/fleet_profiles.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + slugify, + parseProfile, + serializeProfile, + writeProfile, + readProfile, + listProfiles, + slugExists, + deleteProfile, + duplicateProfile, + validateProfile, + type FleetProfile, +} from "../src/fleet_profiles"; + +const SAMPLE_PROFILE: FleetProfile = { + schema: 1, + name: "researcher-opus", + base: "pulse-designer", + model: "anthropic.claude-opus-4-6-v1", + variant: "", + task_type: "interactive", + skills: ["transmon", "atoms", "bosonic"], + gates: [], + permissions: { bash: "allow", file_write: "allow" }, +}; + +describe("slugify", () => { + it("converts name to kebab-case", () => { + expect(slugify("Researcher Opus")).toBe("researcher-opus"); + }); + + it("strips special characters", () => { + expect(slugify("My Profile! (v2)")).toBe("my-profile-v2"); + }); + + it("handles leading/trailing hyphens", () => { + expect(slugify("--test--")).toBe("test"); + }); + + it("returns empty for non-alphanumeric", () => { + expect(slugify("!!!")).toBe(""); + }); +}); + +describe("parseProfile / serializeProfile round-trip", () => { + it("round-trips without data loss", () => { + const toml = serializeProfile(SAMPLE_PROFILE); + const parsed = parseProfile(toml); + expect(parsed).not.toBeNull(); + expect(parsed!.name).toBe(SAMPLE_PROFILE.name); + expect(parsed!.model).toBe(SAMPLE_PROFILE.model); + expect(parsed!.skills).toEqual(SAMPLE_PROFILE.skills); + expect(parsed!.permissions).toEqual(SAMPLE_PROFILE.permissions); + expect(parsed!.schema).toBe(1); + }); + + it("handles empty arrays", () => { + const p = { ...SAMPLE_PROFILE, skills: [], gates: [] }; + const toml = serializeProfile(p); + const parsed = parseProfile(toml); + expect(parsed!.skills).toEqual([]); + expect(parsed!.gates).toEqual([]); + }); + + it("returns null for invalid TOML", () => { + expect(parseProfile("not valid toml [[[")).toBeNull(); + }); +}); + +describe("file operations", () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fleet-profiles-")); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("writeProfile + readProfile round-trips", () => { + writeProfile(SAMPLE_PROFILE, "researcher-opus", tmp); + const read = readProfile(path.join(tmp, "researcher-opus.toml")); + expect(read).not.toBeNull(); + expect(read!.name).toBe("researcher-opus"); + expect(read!.model).toBe("anthropic.claude-opus-4-6-v1"); + }); + + it("writeProfile creates directory if absent", () => { + const nested = path.join(tmp, "sub", "dir"); + writeProfile(SAMPLE_PROFILE, "test", nested); + expect(fs.existsSync(path.join(nested, "test.toml"))).toBe(true); + }); + + it("listProfiles returns all profiles", () => { + writeProfile(SAMPLE_PROFILE, "one", tmp); + writeProfile({ ...SAMPLE_PROFILE, name: "two" }, "two", tmp); + const list = listProfiles(tmp); + expect(list).toHaveLength(2); + expect(list.map((p) => p.slug).sort()).toEqual(["one", "two"]); + }); + + it("listProfiles returns empty for missing directory", () => { + expect(listProfiles("/nonexistent/path")).toEqual([]); + }); + + it("slugExists checks file existence", () => { + writeProfile(SAMPLE_PROFILE, "existing", tmp); + expect(slugExists("existing", tmp)).toBe(true); + expect(slugExists("nonexistent", tmp)).toBe(false); + }); + + it("deleteProfile removes the file", () => { + writeProfile(SAMPLE_PROFILE, "todelete", tmp); + expect(deleteProfile("todelete", tmp)).toBe(true); + expect(slugExists("todelete", tmp)).toBe(false); + }); + + it("deleteProfile returns false for missing file", () => { + expect(deleteProfile("ghost", tmp)).toBe(false); + }); + + it("duplicateProfile creates a copy with -copy suffix", () => { + writeProfile(SAMPLE_PROFILE, "original", tmp); + const newSlug = duplicateProfile("original", tmp); + expect(newSlug).toBe("original-copy"); + const copy = readProfile(path.join(tmp, "original-copy.toml")); + expect(copy).not.toBeNull(); + expect(copy!.name).toBe("researcher-opus (copy)"); + expect(copy!.model).toBe(SAMPLE_PROFILE.model); + }); + + it("duplicateProfile increments suffix on conflict", () => { + writeProfile(SAMPLE_PROFILE, "base", tmp); + writeProfile(SAMPLE_PROFILE, "base-copy", tmp); + const newSlug = duplicateProfile("base", tmp); + expect(newSlug).toBe("base-copy-2"); + }); + + it("duplicateProfile returns null for missing source", () => { + expect(duplicateProfile("nonexistent", tmp)).toBeNull(); + }); +}); + +describe("validateProfile", () => { + it("passes for a valid profile", () => { + expect(validateProfile(SAMPLE_PROFILE)).toEqual([]); + }); + + it("fails for missing name", () => { + const errors = validateProfile({ ...SAMPLE_PROFILE, name: "" }); + expect(errors).toContain("Name is required"); + }); + + it("fails for missing model", () => { + const errors = validateProfile({ ...SAMPLE_PROFILE, model: "" }); + expect(errors).toContain("Model is required"); + }); + + it("fails for name that produces empty slug", () => { + const errors = validateProfile({ ...SAMPLE_PROFILE, name: "!!!" }); + expect(errors).toContain("Name must produce a valid slug (at least one alphanumeric character)"); + }); +}); diff --git a/packages/extension/test/fleet_topology.test.ts b/packages/extension/test/fleet_topology.test.ts new file mode 100644 index 00000000..cbe75840 --- /dev/null +++ b/packages/extension/test/fleet_topology.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, it, expect } from "vitest"; +import { buildTopologyFromConfig } from "../src/fleet_topology_data"; +import { computeNodePositions, buildPopoverContent } from "../media/ui/components/fleet_topology"; +import type { FleetNode } from "../src/fleet_panel"; + +// ── Topology data (host-side) ─────────────────────────────────────────────── + +describe("buildTopologyFromConfig", () => { + it("returns empty for standalone (no config)", () => { + const { nodes, edges } = buildTopologyFromConfig(null); + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + }); + + it("returns empty for standalone role", () => { + const { nodes, edges } = buildTopologyFromConfig({ role: "standalone" }); + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + }); + + it("builds server + client for client role", () => { + const { nodes, edges } = buildTopologyFromConfig( + { role: "client", canonical: { host: "remote-server", port: 4096, sshAlias: "srv" } }, + { localHostname: "my-laptop", serverHealthy: true }, + ); + expect(nodes).toHaveLength(2); + expect(nodes[0].role).toBe("server"); + expect(nodes[0].hostname).toBe("remote-server"); + expect(nodes[0].isLocal).toBe(false); + expect(nodes[1].role).toBe("client"); + expect(nodes[1].hostname).toBe("my-laptop"); + expect(nodes[1].isLocal).toBe(true); + expect(edges).toHaveLength(1); + expect(edges[0].connected).toBe(true); + }); + + it("marks edge as disconnected when server is unhealthy", () => { + const { edges } = buildTopologyFromConfig( + { role: "client", canonical: { host: "srv", port: 4096 } }, + { localHostname: "me", serverHealthy: false }, + ); + expect(edges[0].connected).toBe(false); + }); + + it("builds server-only for server role (no client nodes from config alone)", () => { + const { nodes, edges } = buildTopologyFromConfig( + { role: "server", canonical: { host: "this-machine", port: 4096 } }, + { localHostname: "this-machine" }, + ); + expect(nodes).toHaveLength(1); + expect(nodes[0].role).toBe("server"); + expect(nodes[0].isLocal).toBe(true); + expect(edges).toHaveLength(0); + }); +}); + +// ── Node position computation (webview-side, pure math) ───────────────────── + +describe("computeNodePositions", () => { + it("places server at center", () => { + const nodes: FleetNode[] = [ + { id: "srv", hostname: "server", role: "server", isLocal: true, healthy: true, sessionCount: 0 }, + ]; + const positions = computeNodePositions(nodes, 240, 180); + const srvPos = positions.get("srv")!; + expect(srvPos.x).toBe(120); // center x + expect(srvPos.y).toBe(90); // center y + }); + + it("places clients around server", () => { + const nodes: FleetNode[] = [ + { id: "srv", hostname: "server", role: "server", isLocal: false, healthy: true, sessionCount: 0 }, + { id: "c1", hostname: "client1", role: "client", isLocal: true, healthy: true, sessionCount: 0 }, + { id: "c2", hostname: "client2", role: "client", isLocal: false, healthy: true, sessionCount: 0 }, + ]; + const positions = computeNodePositions(nodes, 240, 180); + expect(positions.size).toBe(3); + + // Clients should be at radius distance from center + const srv = positions.get("srv")!; + const c1 = positions.get("c1")!; + const c2 = positions.get("c2")!; + + const distC1 = Math.sqrt((c1.x - srv.x) ** 2 + (c1.y - srv.y) ** 2); + const distC2 = Math.sqrt((c2.x - srv.x) ** 2 + (c2.y - srv.y) ** 2); + // Both should be at the same radius (within floating point) + expect(distC1).toBeCloseTo(distC2, 5); + // And at 35% of min(240, 180) = 63 + expect(distC1).toBeCloseTo(180 * 0.35, 5); + }); + + it("handles zero clients (server only)", () => { + const nodes: FleetNode[] = [ + { id: "srv", hostname: "server", role: "server", isLocal: true, healthy: true, sessionCount: 0 }, + ]; + const positions = computeNodePositions(nodes, 240, 180); + expect(positions.size).toBe(1); + }); +}); + +// ── Popover content ───────────────────────────────────────────────────────── + +describe("buildPopoverContent", () => { + it("renders hostname, role, health, and sessions", () => { + const node: FleetNode = { + id: "srv", + hostname: "my-server", + role: "server", + isLocal: false, + healthy: true, + sessionCount: 3, + lastSeen: "2 min ago", + }; + const el = buildPopoverContent(node); + const text = el.textContent ?? ""; + expect(text).toContain("my-server"); + expect(text).toContain("server"); + expect(text).toContain("healthy"); + expect(text).toContain("3"); + expect(text).toContain("2 min ago"); + }); + + it("shows 'This machine' for local nodes", () => { + const node: FleetNode = { + id: "me", + hostname: "local-machine", + role: "client", + isLocal: true, + healthy: true, + sessionCount: 0, + }; + const el = buildPopoverContent(node); + expect(el.textContent).toContain("This machine"); + }); + + it("shows 'unreachable' for unhealthy nodes", () => { + const node: FleetNode = { + id: "x", + hostname: "down-host", + role: "client", + isLocal: false, + healthy: false, + sessionCount: 0, + }; + const el = buildPopoverContent(node); + expect(el.textContent).toContain("unreachable"); + }); +}); diff --git a/packages/extension/tools/fleet/install.sh b/packages/extension/tools/fleet/install.sh index 3a845f32..e96d49b9 100755 --- a/packages/extension/tools/fleet/install.sh +++ b/packages/extension/tools/fleet/install.sh @@ -60,41 +60,43 @@ else fi # --- machine-scoped settings (only on darwin; harmless elsewhere) --- +# NOTE: we set amicode.opencodeBinary (to the fleet guard) but NEVER touch +# amicode.opencodePort. In fleet-client mode the extension reads the tunnel port +# from fleet.json (canonical.port), not from the VS Code setting. The port setting +# is the user's standalone preference and must survive fleet enrollment unchanged +# so the local session isn't killed mid-transition. if [[ "$(uname -s)" == "Darwin" ]]; then want_binary="$GUARD_DST" - want_port="$FLEET_PORT" if [[ $CHECK -eq 1 ]]; then # check via node (json with comments not strict) node -e " const fs=require('fs'); const p=process.argv[1]; let j={}; try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){let t=fs.readFileSync(p,'utf8'); j=JSON.parse(t.replace(/\/\/.*|\/\*[\s\S]*?\*\//g,''))} - const b=j['amicode.opencodeBinary']||''; const port=j['amicode.opencodePort']; - const wantPort=Number(process.argv[3]); + const b=j['amicode.opencodeBinary']||''; let fail=0; if(b!==process.argv[2]){console.error('[fleet] FAIL amicode.opencodeBinary is '+(b||'(empty)')+', want '+process.argv[2]); fail=1} - if(port!==wantPort){console.error('[fleet] FAIL amicode.opencodePort is '+port+', want '+wantPort); fail=1} process.exit(fail); - " "$SETTINGS" "$want_binary" "$want_port" || exit 1 - say "ok settings $SETTINGS (binary + port $want_port)" + " "$SETTINGS" "$want_binary" || exit 1 + say "ok settings $SETTINGS (binary = guard)" else if [[ -f "$SETTINGS" ]]; then - # merge via node — preserve other settings, force fleet keys (machine scope) + # merge via node — preserve other settings, set binary to guard only node -e " - const fs=require('fs'), p=process.argv[1], b=process.argv[2], port=Number(process.argv[3]); + const fs=require('fs'), p=process.argv[1], b=process.argv[2]; let j={}; try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){ j={} } // tolerate jsonc: strip // and /* */ try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(_){ try{ const t=fs.readFileSync(p,'utf8').replace(/\/\/.*|\/\*[\s\S]*?\*\//g,''); j=JSON.parse(t)}catch(__){ j={} } } - j['amicode.opencodeBinary']=b; j['amicode.opencodePort']=port; + j['amicode.opencodeBinary']=b; fs.mkdirSync(require('path').dirname(p),{recursive:true}); fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n'); console.log('[fleet] wrote settings '+p); - " "$SETTINGS" "$want_binary" "$want_port" + " "$SETTINGS" "$want_binary" else mkdir -p "$(dirname "$SETTINGS")" - printf '{\n "amicode.opencodeBinary": "%s",\n "amicode.opencodePort": %d\n}\n' "$want_binary" "$want_port" > "$SETTINGS" + printf '{\n "amicode.opencodeBinary": "%s"\n}\n' "$want_binary" > "$SETTINGS" say "wrote new settings $SETTINGS" fi fi diff --git a/tools/fleet/install.sh b/tools/fleet/install.sh index 3a845f32..e96d49b9 100755 --- a/tools/fleet/install.sh +++ b/tools/fleet/install.sh @@ -60,41 +60,43 @@ else fi # --- machine-scoped settings (only on darwin; harmless elsewhere) --- +# NOTE: we set amicode.opencodeBinary (to the fleet guard) but NEVER touch +# amicode.opencodePort. In fleet-client mode the extension reads the tunnel port +# from fleet.json (canonical.port), not from the VS Code setting. The port setting +# is the user's standalone preference and must survive fleet enrollment unchanged +# so the local session isn't killed mid-transition. if [[ "$(uname -s)" == "Darwin" ]]; then want_binary="$GUARD_DST" - want_port="$FLEET_PORT" if [[ $CHECK -eq 1 ]]; then # check via node (json with comments not strict) node -e " const fs=require('fs'); const p=process.argv[1]; let j={}; try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){let t=fs.readFileSync(p,'utf8'); j=JSON.parse(t.replace(/\/\/.*|\/\*[\s\S]*?\*\//g,''))} - const b=j['amicode.opencodeBinary']||''; const port=j['amicode.opencodePort']; - const wantPort=Number(process.argv[3]); + const b=j['amicode.opencodeBinary']||''; let fail=0; if(b!==process.argv[2]){console.error('[fleet] FAIL amicode.opencodeBinary is '+(b||'(empty)')+', want '+process.argv[2]); fail=1} - if(port!==wantPort){console.error('[fleet] FAIL amicode.opencodePort is '+port+', want '+wantPort); fail=1} process.exit(fail); - " "$SETTINGS" "$want_binary" "$want_port" || exit 1 - say "ok settings $SETTINGS (binary + port $want_port)" + " "$SETTINGS" "$want_binary" || exit 1 + say "ok settings $SETTINGS (binary = guard)" else if [[ -f "$SETTINGS" ]]; then - # merge via node — preserve other settings, force fleet keys (machine scope) + # merge via node — preserve other settings, set binary to guard only node -e " - const fs=require('fs'), p=process.argv[1], b=process.argv[2], port=Number(process.argv[3]); + const fs=require('fs'), p=process.argv[1], b=process.argv[2]; let j={}; try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){ j={} } // tolerate jsonc: strip // and /* */ try{j=JSON.parse(fs.readFileSync(p,'utf8'))}catch(_){ try{ const t=fs.readFileSync(p,'utf8').replace(/\/\/.*|\/\*[\s\S]*?\*\//g,''); j=JSON.parse(t)}catch(__){ j={} } } - j['amicode.opencodeBinary']=b; j['amicode.opencodePort']=port; + j['amicode.opencodeBinary']=b; fs.mkdirSync(require('path').dirname(p),{recursive:true}); fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n'); console.log('[fleet] wrote settings '+p); - " "$SETTINGS" "$want_binary" "$want_port" + " "$SETTINGS" "$want_binary" else mkdir -p "$(dirname "$SETTINGS")" - printf '{\n "amicode.opencodeBinary": "%s",\n "amicode.opencodePort": %d\n}\n' "$want_binary" "$want_port" > "$SETTINGS" + printf '{\n "amicode.opencodeBinary": "%s"\n}\n' "$want_binary" > "$SETTINGS" say "wrote new settings $SETTINGS" fi fi