Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<section id="config" class="config-wrap"></section>
<section id="config-editor" class="cfg-editor-wrap" hidden>
<div class="cfg-editor-head">
<span class="cfg-label">edit fleets.toml</span>
<span class="cfg-label" id="cfg-editor-title">edit fleets.toml</span>
<span class="cfg-editor-path" id="cfg-editor-path"></span>
<span class="cfg-editor-spacer"></span>
<button class="cfg-btn" id="cfg-save" type="button">Save</button>
Expand All @@ -31,6 +31,7 @@
<div class="cfg-editor-error" id="cfg-editor-error" hidden></div>
</section>
<section id="identity" class="identity-wrap"></section>
<section id="remote" class="remote-wrap"></section>
<section class="logs">
<nav class="tabs" id="tabs">
<button class="tab is-active" data-target="log">Activity</button>
Expand Down
22 changes: 21 additions & 1 deletion console/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
import type { Deployment, FleetConfig, RuntimeContext } from "./types";
import type {
Deployment,
FleetConfig,
RemoteConfig,
RuntimeContext,
} from "./types";

// Stand-in remote-connection view so the browser build renders the remote panel
// without a core. A configured-but-disconnected example — the shape the panel
// shows before the operator hits "Activate".
export const FIXTURE_REMOTE_CONFIG: RemoteConfig = {
path: "~/.config/oab-studio/remote.toml",
text: `# OAB Studio — remote reverse-MCP connection (the /acp endpoint Studio dials)
url = "wss://gateway.example/acp"
token = "…"
cwd = "/"
`,
url: "wss://gateway.example/acp",
configured: true,
status: "disconnected",
};

// Stand-in data so the console renders without a live core. Mirrors the shape
// studio-cp's `deploy_list` / `deploy_get` return. Swapped for the Tauri source
Expand Down
121 changes: 106 additions & 15 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {
renderRoster,
renderIdentity,
renderFleetConfig,
renderRemote,
filterByMembers,
} from "./render";
import type { FleetConfig } from "./types";
import type { FleetConfig, RemoteConfig } from "./types";
import { createPane, bindBackend, type Level } from "./log";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
Expand All @@ -25,14 +26,17 @@ let activeFleet: string | null = null;
let activeCluster = DEFAULT_CLUSTER;
let activeMembers: string[] = [];
let fleetConfig: FleetConfig | null = null;
let remoteConfig: RemoteConfig | null = null;

const roster = document.getElementById("roster");
const identityEl = document.getElementById("identity");
const configEl = document.getElementById("config");
const remoteEl = document.getElementById("remote");
const editorSection = document.getElementById("config-editor");
const editorMount = document.getElementById("cfg-editor-mount");
const editorError = document.getElementById("cfg-editor-error");
const editorPathEl = document.getElementById("cfg-editor-path");
const editorTitleEl = document.getElementById("cfg-editor-title");
const saveBtn = document.getElementById("cfg-save") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("cfg-cancel") as HTMLButtonElement | null;
const clusterLabel = document.getElementById("cluster-label");
Expand Down Expand Up @@ -147,6 +151,21 @@ async function refreshConfig(): Promise<void> {
}
}

// The remote reverse-MCP connection panel (Part B). Fetched on boot; the live
// status is also pushed via the `remote-status` event (see boot), so this is the
// initial render + a refresh after an activate/deactivate/save action.
async function refreshRemote(): Promise<void> {
if (!remoteEl) return;
try {
remoteConfig = await source.remoteConfig();
renderRemote(remoteEl, remoteConfig);
} catch (e) {
note("error", `remote config: ${errText(e)}`);
remoteConfig = null;
renderRemote(remoteEl, null);
}
}

// Switch the active fleet by identity (name): re-point every read at its cluster
// (and thus its bound credential), filter the roster to its members, and refresh
// immediately — so "switch fleet" == "switch managing account + roster" the ADR
Expand All @@ -166,27 +185,35 @@ function selectFleet(name: string): void {
void tick();
}

// ---- fleets.toml editor (ADR #19 slice C: the "edit" side) -------------------
// A CodeMirror TOML editor over the raw config file. Kept imperative (CM owns
// real DOM) and separate from the re-rendered config panel, so switching fleets
// never wipes an open editor.
// ---- TOML editor (fleets.toml + remote.toml) ---------------------------------
// One CodeMirror TOML editor, shared by both config files (which one is set by
// `editorTarget`). Kept imperative (CM owns real DOM) and separate from the
// re-rendered panels, so a background refresh never wipes an open editor.
type EditorTarget = "fleet" | "remote";
let editorView: EditorView | null = null;
let editorTarget: EditorTarget = "fleet";

function showEditorError(msg: string | null): void {
if (!editorError) return;
editorError.textContent = msg ?? "";
editorError.hidden = !msg;
}

function openEditor(): void {
function openEditor(target: EditorTarget): void {
if (!editorSection || !editorMount) return;
editorTarget = target;
const isRemote = target === "remote";
const doc = (isRemote ? remoteConfig?.text : fleetConfig?.text) ?? "";
const path = (isRemote ? remoteConfig?.path : fleetConfig?.path) ?? "";
showEditorError(null);
if (editorPathEl) editorPathEl.textContent = fleetConfig?.path ?? "";
if (editorTitleEl)
editorTitleEl.textContent = isRemote ? "edit remote.toml" : "edit fleets.toml";
if (editorPathEl) editorPathEl.textContent = path;
editorView?.destroy();
editorView = new EditorView({
parent: editorMount,
state: EditorState.create({
doc: fleetConfig?.text ?? "",
doc,
extensions: [basicSetup, StreamLanguage.define(toml)],
}),
});
Expand All @@ -208,12 +235,19 @@ async function saveEditor(): Promise<void> {
showEditorError(null);
try {
// The backend validates the TOML and rejects (without writing) on error.
fleetConfig = await source.writeFleetConfig(text);
if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet);
note("info", "fleet config saved");
closeEditor();
// A binding change may alter the active fleet's credential — re-observe.
void refreshIdentity();
if (editorTarget === "remote") {
remoteConfig = await source.writeRemoteConfig(text);
if (remoteEl) renderRemote(remoteEl, remoteConfig);
note("info", "remote config saved");
closeEditor();
} else {
fleetConfig = await source.writeFleetConfig(text);
if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet);
note("info", "fleet config saved");
closeEditor();
// A binding change may alter the active fleet's credential — re-observe.
void refreshIdentity();
}
} catch (e) {
showEditorError(`save failed — ${errText(e)}`);
} finally {
Expand All @@ -230,14 +264,45 @@ if (configEl) {
configEl.addEventListener("click", (ev) => {
const target = ev.target as HTMLElement;
if (target.closest('[data-action="edit-config"]')) {
openEditor();
openEditor("fleet");
return;
}
const btn = target.closest<HTMLElement>("[data-fleet]");
if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet);
});
}

// The remote panel: "Edit config" opens remote.toml in the editor; "Activate"
// dials the /acp endpoint; "Disconnect" tears it down. Status then updates via
// the `remote-status` event, with a refresh as a fallback.
async function remoteAction(kind: "connect" | "disconnect"): Promise<void> {
try {
if (kind === "connect") {
await source.remoteConnect();
note("info", "activating remote connection…");
} else {
await source.remoteDisconnect();
note("info", "remote connection deactivated");
}
} catch (e) {
note("error", `remote ${kind}: ${errText(e)}`);
}
void refreshRemote();
}

if (remoteEl) {
remoteEl.addEventListener("click", (ev) => {
const target = ev.target as HTMLElement;
if (target.closest('[data-action="edit-remote-config"]')) {
openEditor("remote");
} else if (target.closest('[data-action="remote-connect"]')) {
void remoteAction("connect");
} else if (target.closest('[data-action="remote-disconnect"]')) {
void remoteAction("disconnect");
}
});
}

// ---- start / stop (ADR-2 write model: stop = scale→0, start = scale→1) -------
// Scale a deployment off (0) or on (1). Reversible — ECS keeps the Spec at
// desiredCount 0 — so this needs no state store. On success `tick()` re-renders
Expand Down Expand Up @@ -369,17 +434,43 @@ function setupUpdater(): void {
el.addEventListener("click", () => void (pending ? install(el, invoke) : check(el, invoke)));
}

// Live remote-connection status: the backend pushes `remote-status` events as the
// transport connects / drops / errors, so the panel reflects the real state
// without polling. Browser build (no `__TAURI__`) simply skips it.
async function bindRemoteStatus(): Promise<void> {
interface EventGlobal {
event?: {
listen?: <T>(
e: string,
h: (e: { payload: T }) => void,
) => Promise<unknown>;
};
}
const listen = (globalThis as { __TAURI__?: EventGlobal }).__TAURI__?.event
?.listen;
if (!listen) return;
await listen<{ status: string }>("remote-status", (e) => {
const status = e.payload?.status ?? "disconnected";
if (remoteConfig) {
remoteConfig = { ...remoteConfig, status };
if (remoteEl) renderRemote(remoteEl, remoteConfig);
}
});
}

// Boot order matters: subscribe to the log streams FIRST, then start the core,
// so the spawn → handshake → ready lifecycle lines are captured, not lost.
async function boot(): Promise<void> {
note("info", `OAB Studio ${BUILD} (built ${__BUILD_TIME__})`);
if (activity && mcp) await bindBackend(activity, mcp);
await bindRemoteStatus();
if (clusterLabel) clusterLabel.textContent = activeCluster;
note("info", `polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`);
setupUpdater();
await startCore();
void refreshConfig();
void refreshIdentity();
void refreshRemote();
void tick();
window.setInterval(() => void tick(), POLL_MS);
}
Expand Down
59 changes: 59 additions & 0 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
rosterHtml,
identityHtml,
fleetConfigHtml,
remoteHtml,
filterByMembers,
serviceName,
} from "./render";
import {
FIXTURE_DEPLOYMENTS,
FIXTURE_FLEET_CONFIG,
FIXTURE_REMOTE_CONFIG,
FIXTURE_RUNTIME_CONTEXT,
} from "./fixtures";
import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types";
Expand Down Expand Up @@ -238,6 +240,63 @@ describe("fleetConfigHtml", () => {
});
});

describe("remoteHtml", () => {
it("shows the endpoint and an Activate button when configured + disconnected", () => {
const html = remoteHtml(FIXTURE_REMOTE_CONFIG);
expect(html).toContain("wss://gateway.example/acp");
expect(html).toContain('data-action="remote-connect"');
expect(html).toContain("Activate remote connection");
expect(html).not.toContain("disabled");
// status badge reflects the state
expect(html).toContain("rm-disconnected");
});

it("disables Activate until url + token are configured", () => {
const html = remoteHtml({
...FIXTURE_REMOTE_CONFIG,
configured: false,
url: "",
});
expect(html).toContain('data-action="remote-connect"');
expect(html).toContain("disabled");
expect(html).toContain("not configured");
});

it("shows Disconnect (not Activate) while connected", () => {
const html = remoteHtml({ ...FIXTURE_REMOTE_CONFIG, status: "connected" });
expect(html).toContain('data-action="remote-disconnect"');
expect(html).toContain("rm-connected");
expect(html).not.toContain('data-action="remote-connect"');
});

it("marks an error status", () => {
const html = remoteHtml({
...FIXTURE_REMOTE_CONFIG,
status: "error: dial refused",
});
expect(html).toContain("rm-error");
expect(html).toContain("error: dial refused");
// an error is not "live", so it offers Activate again
expect(html).toContain('data-action="remote-connect"');
});

it("always offers Edit config", () => {
expect(remoteHtml(FIXTURE_REMOTE_CONFIG)).toContain(
'data-action="edit-remote-config"',
);
});

it("renders an unavailable state for null", () => {
expect(remoteHtml(null)).toContain("remote connection unavailable");
});

it("escapes the url", () => {
const html = remoteHtml({ ...FIXTURE_REMOTE_CONFIG, url: "wss://<x>/acp" });
expect(html).toContain("&lt;x&gt;");
expect(html).not.toContain("<x>/acp");
});
});

describe("filterByMembers", () => {
it("keeps only deployments whose ECS service name is a member", () => {
const kept = filterByMembers(FIXTURE_DEPLOYMENTS, ["oab-prod-orca"]);
Expand Down
54 changes: 54 additions & 0 deletions console/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AgentState,
Deployment,
FleetConfig,
RemoteConfig,
RuntimeContext,
} from "./types";

Expand Down Expand Up @@ -227,3 +228,56 @@ export function renderFleetConfig(
): void {
el.innerHTML = fleetConfigHtml(cfg, activeFleet);
}

// ---- Remote reverse-MCP connection panel (Part B) ---------------------------
// The "activate the remote connection" surface: the /acp endpoint, live status,
// an explicit Activate/Disconnect button, and Edit config (the same editor as
// fleets.toml, over remote.toml).

const REMOTE_STATUS_CLASS: Record<string, string> = {
connected: "rm-connected",
connecting: "rm-connecting",
disconnected: "rm-disconnected",
error: "rm-error",
};

function remoteStatusBadge(status: string): string {
const key = status.startsWith("error") ? "error" : status;
const cls = REMOTE_STATUS_CLASS[key] ?? "rm-disconnected";
return `<span class="rm-status ${cls}">${escapeHtml(status || "disconnected")}</span>`;
}

// Pure: the remote-connection view -> the panel HTML. `null` renders an
// unavailable state. The button is Disconnect while connected/connecting, else
// Activate — disabled until a URL + token are configured.
export function remoteHtml(view: RemoteConfig | null): string {
if (!view) {
return `<div class="remote"><span class="muted">remote connection unavailable</span></div>`;
}
const live = view.status === "connected" || view.status === "connecting";
const action = live
? `<button class="rm-btn rm-disconnect" type="button" data-action="remote-disconnect">Disconnect</button>`
: `<button class="rm-btn rm-connect" type="button" data-action="remote-connect"${view.configured ? "" : " disabled"}>Activate remote connection</button>`;
const target = view.configured
? `<code class="rm-url">${escapeHtml(view.url)}</code>`
: `<span class="muted">not configured — set <code>url</code> + <code>token</code> in the config</span>`;
const path = view.path
? `<span class="cfg-path" title="edit this file to configure the remote connection"><code>${escapeHtml(view.path)}</code></span>`
: "";
return `<div class="remote">
<div class="rm-head">
<span class="cfg-label">remote</span>
${path}
<button class="cfg-edit" type="button" data-action="edit-remote-config">Edit config</button>
</div>
<div class="rm-body">
${remoteStatusBadge(view.status)}
<span class="rm-target">${target}</span>
${action}
</div>
</div>`;
}

export function renderRemote(el: HTMLElement, view: RemoteConfig | null): void {
el.innerHTML = remoteHtml(view);
}
Loading
Loading