diff --git a/console/index.html b/console/index.html
index af031f0..5d29f1e 100644
--- a/console/index.html
+++ b/console/index.html
@@ -36,7 +36,7 @@
alongside the Fleets/Fleet-detail/Agent-console drill-down, and stays
visible at every depth — it is a conversation with the designated
management agent, not with whichever fleet/agent is selected. -->
-
+
@@ -98,6 +98,76 @@
+
+
+
+ Deploy
+
+
+
+
+
+
Compose
+
+
+
+
+
edit fleets.toml
diff --git a/console/src/deploy.ts b/console/src/deploy.ts
new file mode 100644
index 0000000..8f40fc9
--- /dev/null
+++ b/console/src/deploy.ts
@@ -0,0 +1,275 @@
+// The deploy action panel (ADR #83 §7.5): `[+ New fleet]` (7.2) and `[+ Add
+// instance]` (7.3) both land here — the same compose→preview→deploy engine
+// (agent-deployment-templates.md, reused via `compose.ts`'s exported pure
+// helpers), differing only in whether a fleet-identity step runs first.
+//
+// After a successful `deploy_provision`, this module computes the updated
+// `fleets.toml` text (`fleetToml.ts`, pure) and persists it via
+// `source.writeFleetConfig` — per the ADR, `fleets.toml` is only ever mutated
+// after a confirmed successful provision, never before or speculatively.
+
+import type { Source } from "./source";
+import { libraryNames, renderPreviewHtml, type Library, type BundlePreview } from "./compose";
+import { appendMember, appendFleetBlock } from "./fleetToml";
+
+type Invoke = (cmd: string, args?: Record) => Promise;
+
+function tauriInvoke(): Invoke | null {
+ const t = (globalThis as { __TAURI__?: { core?: { invoke?: Invoke } } }).__TAURI__;
+ return t?.core?.invoke ?? null;
+}
+
+function errText(e: unknown): string {
+ return e instanceof Error ? e.message : String(e);
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function fillOptions(sel: HTMLSelectElement, names: string[], keepNoneFirst: boolean): void {
+ const opts = keepNoneFirst ? [''] : [];
+ for (const n of names) opts.push(``);
+ sel.innerHTML = opts.join("");
+}
+
+export type DeployMode = { kind: "new-fleet" } | { kind: "add-instance"; fleetName: string };
+
+// What the panel reports back once a deploy + fleets.toml write both succeed —
+// enough for the caller (`main.ts`) to log it and re-derive screen state
+// (select the fleet, refresh the roster) without this module reaching into
+// main.ts's own state.
+export interface DeployedInfo {
+ fleetName: string;
+ service: string;
+ image: string;
+}
+
+export interface DeployPanelDeps {
+ source: Source;
+ onDeployed(info: DeployedInfo): void | Promise;
+}
+
+export interface DeployPanelHandle {
+ open(mode: DeployMode): void;
+ close(): void;
+}
+
+// Wires the `#deploy-wrap` panel declared in index.html. `null` if the DOM
+// isn't present (mirrors the rest of the console's init* functions).
+export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null {
+ const wrap = document.getElementById("deploy-wrap");
+ const drilldownRow = document.getElementById("drilldown-row");
+ const titleEl = document.getElementById("deploy-title");
+ const cancelBtn = document.getElementById("deploy-cancel") as HTMLButtonElement | null;
+ const identityForm = document.getElementById("deploy-identity-form") as HTMLFormElement | null;
+ const nameInput = document.getElementById("deploy-fleet-name") as HTMLInputElement | null;
+ const regionInput = document.getElementById("deploy-region") as HTMLInputElement | null;
+ const profileInput = document.getElementById("deploy-profile") as HTMLInputElement | null;
+ const principalInput = document.getElementById("deploy-principal") as HTMLInputElement | null;
+ const identityStatusEl = document.getElementById("deploy-identity-status");
+ const composeSection = document.getElementById("deploy-compose");
+ const composeHeading = document.getElementById("deploy-compose-heading");
+ const tmplSel = document.getElementById("deploy-template") as HTMLSelectElement | null;
+ const ovlSel = document.getElementById("deploy-overlay") as HTMLSelectElement | null;
+ const previewForm = document.getElementById("deploy-form") as HTMLFormElement | null;
+ const previewOut = document.getElementById("deploy-preview");
+ const previewStatusEl = document.getElementById("deploy-status");
+ const deployForm = document.getElementById("deploy-deploy-form") as HTMLFormElement | null;
+ const agentNameInput = document.getElementById("deploy-name") as HTMLInputElement | null;
+ const imageInput = document.getElementById("deploy-image") as HTMLInputElement | null;
+ const deployBtn = document.getElementById("deploy-deploy-btn") as HTMLButtonElement | null;
+ const deployStatusEl = document.getElementById("deploy-deploy-status");
+
+ if (
+ !wrap ||
+ !cancelBtn ||
+ !identityForm ||
+ !nameInput ||
+ !regionInput ||
+ !profileInput ||
+ !principalInput ||
+ !composeSection ||
+ !tmplSel ||
+ !ovlSel ||
+ !previewForm ||
+ !deployForm ||
+ !agentNameInput ||
+ !imageInput ||
+ !deployBtn
+ ) {
+ return null;
+ }
+
+ let mode: DeployMode | null = null;
+ let library: Library | null = null;
+
+ const setStatus = (el: HTMLElement | null, msg: string, cls = ""): void => {
+ if (!el) return;
+ el.textContent = msg;
+ el.className = cls ? `compose-status ${cls}` : "compose-status";
+ };
+
+ const reset = (): void => {
+ identityForm.reset();
+ previewForm.reset();
+ deployForm.reset();
+ deployForm.hidden = true;
+ if (previewOut) previewOut.innerHTML = "";
+ setStatus(identityStatusEl, "");
+ setStatus(previewStatusEl, "");
+ setStatus(deployStatusEl, "");
+ };
+
+ const loadLibraryAndPickers = async (): Promise => {
+ const invoke = tauriInvoke();
+ if (!invoke) {
+ setStatus(previewStatusEl, "browser build — deploy unavailable");
+ return;
+ }
+ try {
+ library = await invoke("compose_library_get");
+ const { templates, overlays } = libraryNames(library);
+ fillOptions(tmplSel, templates, false);
+ fillOptions(ovlSel, overlays, true);
+ } catch (e) {
+ setStatus(previewStatusEl, `library load failed: ${errText(e)}`, "err");
+ }
+ };
+
+ const open = (m: DeployMode): void => {
+ mode = m;
+ reset();
+ if (titleEl) {
+ titleEl.textContent =
+ m.kind === "new-fleet" ? "New fleet — fleet identity" : `${m.fleetName} — add instance`;
+ }
+ identityForm.hidden = m.kind !== "new-fleet";
+ composeSection.hidden = m.kind === "new-fleet";
+ if (composeHeading) {
+ composeHeading.textContent =
+ m.kind === "new-fleet" ? "Step 2 — first instance" : "Compose";
+ }
+ if (drilldownRow) drilldownRow.hidden = true;
+ wrap.hidden = false;
+ void loadLibraryAndPickers();
+ };
+
+ const close = (): void => {
+ mode = null;
+ wrap.hidden = true;
+ if (drilldownRow) drilldownRow.hidden = false;
+ reset();
+ };
+
+ cancelBtn.addEventListener("click", close);
+
+ // Step 1 (new-fleet only): collect the fleet identity, then reveal the
+ // shared Compose step — 7.5.1's "Next: first instance →".
+ identityForm.addEventListener("submit", (ev) => {
+ ev.preventDefault();
+ if (!nameInput.value.trim()) {
+ setStatus(identityStatusEl, "fleet name is required", "err");
+ return;
+ }
+ identityForm.hidden = true;
+ composeSection.hidden = false;
+ if (composeHeading) composeHeading.textContent = "Step 2 — first instance";
+ });
+
+ previewForm.addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ const invoke = tauriInvoke();
+ if (!invoke || !library) {
+ setStatus(previewStatusEl, "deploy unavailable", "err");
+ return;
+ }
+ const template = tmplSel.value;
+ if (!template) {
+ setStatus(previewStatusEl, "pick a template to preview", "err");
+ return;
+ }
+ const overlay = ovlSel.value || null;
+ setStatus(previewStatusEl, "composing…");
+ try {
+ const preview = await invoke("compose_preview", { library, template, overlay });
+ if (previewOut) previewOut.innerHTML = renderPreviewHtml(preview);
+ setStatus(previewStatusEl, `composed — ${preview.files.length} files`, "ok");
+ deployForm.hidden = false;
+ if (!agentNameInput.value) agentNameInput.value = overlay ?? template;
+ imageInput.placeholder = preview.image_tag;
+ setStatus(deployStatusEl, "");
+ } catch (e) {
+ if (previewOut) previewOut.innerHTML = "";
+ deployForm.hidden = true;
+ setStatus(previewStatusEl, `compose failed: ${errText(e)}`, "err");
+ }
+ });
+
+ // The failure rule from 7.5.1/7.5.2: if `deploy_provision` fails, stop — no
+ // `fleet_config_write` call, `fleets.toml` is untouched.
+ deployForm.addEventListener("submit", async (ev) => {
+ ev.preventDefault();
+ const invoke = tauriInvoke();
+ if (!invoke || !library || !mode) {
+ setStatus(deployStatusEl, "deploy unavailable", "err");
+ return;
+ }
+ const template = tmplSel.value;
+ const name = agentNameInput.value.trim();
+ if (!template) {
+ setStatus(deployStatusEl, "preview a template first", "err");
+ return;
+ }
+ if (!name) {
+ setStatus(deployStatusEl, "agent name is required", "err");
+ return;
+ }
+ const overlay = ovlSel.value || null;
+ const namespace = "default";
+ const image = imageInput.value.trim() || null;
+ deployBtn.disabled = true;
+ setStatus(deployStatusEl, "deploying…");
+ let res: { image?: string; digest?: string; objects?: number };
+ try {
+ res = await invoke("deploy_provision", { library, template, overlay, name, namespace, image });
+ } catch (e) {
+ setStatus(deployStatusEl, `deploy failed: ${errText(e)}`, "err");
+ deployBtn.disabled = false;
+ return;
+ }
+ const service = `oab-${namespace}-${name}`;
+ const fleetName = mode.kind === "new-fleet" ? nameInput.value.trim() : mode.fleetName;
+ setStatus(deployStatusEl, `deployed ${service} — updating fleets.toml…`, "ok");
+ try {
+ const current = await deps.source.fleetConfig();
+ const nextText =
+ mode.kind === "new-fleet"
+ ? appendFleetBlock(current.text, {
+ name: fleetName,
+ member: service,
+ region: regionInput.value.trim() || null,
+ profile: profileInput.value.trim() || null,
+ expectedPrincipal: principalInput.value.trim() || null,
+ })
+ : appendMember(current.text, fleetName, service);
+ await deps.source.writeFleetConfig(nextText);
+ } catch (e) {
+ // The instance is live but fleets.toml wasn't updated — surface it
+ // rather than silently leaving the roster's membership stale.
+ setStatus(deployStatusEl, `deployed ${service}, but fleets.toml update failed: ${errText(e)}`, "err");
+ deployBtn.disabled = false;
+ return;
+ }
+ deployBtn.disabled = false;
+ const info: DeployedInfo = { fleetName, service, image: res.image ?? image ?? template };
+ close();
+ await deps.onDeployed(info);
+ });
+
+ return { open, close };
+}
diff --git a/console/src/fleetToml.test.ts b/console/src/fleetToml.test.ts
new file mode 100644
index 0000000..f3d1c19
--- /dev/null
+++ b/console/src/fleetToml.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import { appendMember, appendFleetBlock } from "./fleetToml";
+
+describe("appendMember", () => {
+ const text = `default_cluster = "oab"
+
+[fleet.oab-prod-orca]
+members = ["oab-default-agent-1", "oab-default-agent-2"]
+region = "ap-east-2"
+profile = "oab-fleet"
+
+[fleet.oab-prod-mira]
+members = ["oab-default-mira-1"]
+`;
+
+ it("appends the new member to the named fleet's array", () => {
+ const out = appendMember(text, "oab-prod-orca", "oab-default-agent-3");
+ expect(out).toContain(
+ 'members = ["oab-default-agent-1", "oab-default-agent-2", "oab-default-agent-3"]',
+ );
+ });
+
+ it("leaves region/profile and the rest of the file untouched", () => {
+ const out = appendMember(text, "oab-prod-orca", "oab-default-agent-3");
+ expect(out).toContain('region = "ap-east-2"');
+ expect(out).toContain('profile = "oab-fleet"');
+ expect(out).toContain('[fleet.oab-prod-mira]\nmembers = ["oab-default-mira-1"]');
+ });
+
+ it("only edits the targeted fleet's members array", () => {
+ const out = appendMember(text, "oab-prod-mira", "oab-default-mira-2");
+ expect(out).toContain('members = ["oab-default-mira-1", "oab-default-mira-2"]');
+ expect(out).toContain('members = ["oab-default-agent-1", "oab-default-agent-2"]');
+ });
+
+ it("is a no-op when the member is already listed", () => {
+ const out = appendMember(text, "oab-prod-orca", "oab-default-agent-1");
+ expect(out).toBe(text);
+ });
+
+ it("is a no-op when the fleet isn't found", () => {
+ const out = appendMember(text, "no-such-fleet", "x");
+ expect(out).toBe(text);
+ });
+
+ it("inserts a members line when the block doesn't have one", () => {
+ const noMembers = `[fleet.empty-fleet]\nregion = "ap-east-2"\n`;
+ const out = appendMember(noMembers, "empty-fleet", "oab-default-a1");
+ expect(out).toContain('members = ["oab-default-a1"]');
+ expect(out).toContain('region = "ap-east-2"');
+ });
+});
+
+describe("appendFleetBlock", () => {
+ it("appends a new [fleet.] block with the given fields", () => {
+ const out = appendFleetBlock("default_cluster = \"oab\"\n", {
+ name: "support-fleet",
+ member: "oab-default-support-bot-1",
+ region: "ap-east-2",
+ profile: "oab-fleet",
+ expectedPrincipal: "arn:aws:iam::123:role/oab-fleet",
+ });
+ expect(out).toContain("[fleet.support-fleet]");
+ expect(out).toContain('members = ["oab-default-support-bot-1"]');
+ expect(out).toContain('region = "ap-east-2"');
+ expect(out).toContain('profile = "oab-fleet"');
+ expect(out).toContain('expected_principal = "arn:aws:iam::123:role/oab-fleet"');
+ });
+
+ it("omits optional fields that weren't provided", () => {
+ const out = appendFleetBlock("", {
+ name: "support-fleet",
+ member: "oab-default-support-bot-1",
+ region: null,
+ profile: null,
+ expectedPrincipal: null,
+ });
+ expect(out).not.toContain("region =");
+ expect(out).not.toContain("profile =");
+ expect(out).not.toContain("expected_principal =");
+ });
+
+ it("separates the new block from existing content with exactly one blank line", () => {
+ const out = appendFleetBlock('default_cluster = "oab"\n', {
+ name: "x",
+ member: "m",
+ region: null,
+ profile: null,
+ expectedPrincipal: null,
+ });
+ expect(out).toBe('default_cluster = "oab"\n\n[fleet.x]\nmembers = ["m"]\n');
+ });
+});
diff --git a/console/src/fleetToml.ts b/console/src/fleetToml.ts
new file mode 100644
index 0000000..824da9c
--- /dev/null
+++ b/console/src/fleetToml.ts
@@ -0,0 +1,77 @@
+// Pure text-level edits to fleets.toml's `[fleet.]` blocks — the
+// client-side half of ADR #83 §7.5's deploy flows. `fleet_config_write` has no
+// partial/append primitive ("overwrites the operator's fleets.toml" per
+// oab-mcp's tool description), so both `[+ New fleet]` (7.5.1) and `[+ Add
+// instance]` (7.5.2) compute the new/edited TOML client-side and call
+// `fleet_config_write` with the full updated text. Kept side-effect-free and
+// regex-based (not a full TOML parser) so it's unit-testable and only ever
+// touches the one array/block it means to.
+
+function quote(s: string): string {
+ return JSON.stringify(s);
+}
+
+// Locate a `[fleet.]` table's span within `text`. `headerEnd` is where
+// the block's body starts (right after the header line); `end` is either the
+// next top-level `[...]` table header or the end of the file. `null` if the
+// fleet isn't present.
+function findFleetBlock(
+ text: string,
+ name: string,
+): { start: number; end: number; headerEnd: number } | null {
+ const header = `[fleet.${name}]`;
+ const start = text.indexOf(header);
+ if (start === -1) return null;
+ const headerEnd = start + header.length;
+ const rest = text.slice(headerEnd);
+ const next = rest.match(/\n\[/);
+ const end = next && next.index !== undefined ? headerEnd + next.index + 1 : text.length;
+ return { start, end, headerEnd };
+}
+
+// Append `member` to an existing fleet's `members = [...]` array (single-line
+// TOML array — the only form fleets.toml is written in today, per the ADR's
+// mockups). A no-op if the member is already listed or the fleet isn't found.
+// If the block has no `members` line yet, one is inserted right after the
+// header. `region`/`profile`/`expected_principal` are untouched (7.5.2:
+// inherited from the fleet the operator already drilled into).
+export function appendMember(text: string, fleetName: string, member: string): string {
+ const block = findFleetBlock(text, fleetName);
+ if (!block) return text;
+ const body = text.slice(block.headerEnd, block.end);
+ const arrayLine = body.match(/^([ \t]*members\s*=\s*)\[([^\]]*)\]/m);
+ if (!arrayLine || arrayLine.index === undefined) {
+ return `${text.slice(0, block.headerEnd)}\nmembers = [${quote(member)}]${body}${text.slice(block.end)}`;
+ }
+ const items = arrayLine[2]
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const existing = items.map((s) => s.replace(/^["']|["']$/g, ""));
+ if (existing.includes(member)) return text;
+ items.push(quote(member));
+ const newLine = `${arrayLine[1]}[${items.join(", ")}]`;
+ const newBody = body.slice(0, arrayLine.index) + newLine + body.slice(arrayLine.index + arrayLine[0].length);
+ return text.slice(0, block.headerEnd) + newBody + text.slice(block.end);
+}
+
+export interface NewFleetEntry {
+ name: string;
+ member: string;
+ region: string | null;
+ profile: string | null;
+ expectedPrincipal: string | null;
+}
+
+// Append a brand-new `[fleet.]` block to the end of the file (7.5.1 step
+// 2), with the one member — the first instance just deployed. Optional fields
+// are omitted rather than written as empty strings.
+export function appendFleetBlock(text: string, entry: NewFleetEntry): string {
+ const lines = [`[fleet.${entry.name}]`, `members = [${quote(entry.member)}]`];
+ if (entry.region) lines.push(`region = ${quote(entry.region)}`);
+ if (entry.profile) lines.push(`profile = ${quote(entry.profile)}`);
+ if (entry.expectedPrincipal) lines.push(`expected_principal = ${quote(entry.expectedPrincipal)}`);
+ const block = `${lines.join("\n")}\n`;
+ const trimmed = text.replace(/\s*$/, "");
+ return trimmed.length ? `${trimmed}\n\n${block}` : block;
+}
diff --git a/console/src/main.ts b/console/src/main.ts
index 5352414..1f7fb7b 100644
--- a/console/src/main.ts
+++ b/console/src/main.ts
@@ -18,6 +18,7 @@ import type {
} from "./types";
import { createChatPanel, type ChatPanel } from "./chatPanel";
import { initAgentConsole, type AgentConsole } from "./agentConsole";
+import { initDeployPanel, type DeployPanelHandle, type DeployedInfo } from "./deploy";
import { createPane, bindBackend, type Level } from "./log";
import { initThemeToggle } from "./theme";
import { EditorView, basicSetup } from "codemirror";
@@ -326,6 +327,33 @@ function deselectFleet(): void {
void tick();
}
+// After a deploy panel run succeeds (deploy_provision + fleets.toml write both
+// landed — `deploy.ts` guarantees that ordering): re-read fleets.toml, then
+// either land on the new fleet's detail screen (7.5.1 step 4) or, if we're
+// already looking at the fleet an instance was just added to, just refresh its
+// member filter + roster in place (7.5.2 step 4).
+async function handleDeployed(info: DeployedInfo): Promise {
+ note("info", `deploy: ${info.service} @ ${info.image} provisioned, fleets.toml updated (${info.fleetName})`);
+ try {
+ fleetConfig = await source.fleetConfig();
+ } catch (e) {
+ note("error", `config: fleet reload failed — ${errText(e)}`);
+ }
+ if (activeFleet === info.fleetName) {
+ const fleet = fleetConfig?.fleets.find((f) => f.name === info.fleetName);
+ if (fleet) activeMembers = fleet.members;
+ if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet);
+ void tick();
+ } else {
+ selectFleet(info.fleetName);
+ }
+}
+
+const deployPanel: DeployPanelHandle | null = initDeployPanel({
+ source,
+ onDeployed: handleDeployed,
+});
+
// Fleet detail shows either the members roster or the open Agent console, never
// both (Part A: "the only navigation model," no tab-peer surfaces) — mirror
// `#agent-console`'s own `hidden` state (owned by `agentConsole.ts`, untouched)
@@ -447,17 +475,28 @@ if (configEl) {
void openEditor("fleet");
return;
}
+ if (target.closest('[data-action="new-fleet"]')) {
+ deployPanel?.open({ kind: "new-fleet" });
+ return;
+ }
const btn = target.closest("[data-fleet]");
if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet);
});
}
-// The Fleet detail header: only "← Fleets" is wired this slice — `+ Add
-// instance` / `⚙` render disabled (slices 5/6 wire them).
+// The Fleet detail header: "← Fleets" backs out; "+ Add instance" opens the
+// deploy panel scoped to the active fleet (7.5.2). `⚙` still renders disabled
+// (slice 6 wires the Debug drawer).
if (fleetDetailEl) {
fleetDetailEl.addEventListener("click", (ev) => {
const target = ev.target as HTMLElement;
- if (target.closest('[data-action="back-to-fleets"]')) deselectFleet();
+ if (target.closest('[data-action="back-to-fleets"]')) {
+ deselectFleet();
+ return;
+ }
+ if (target.closest('[data-action="add-instance"]') && activeFleet) {
+ deployPanel?.open({ kind: "add-instance", fleetName: activeFleet });
+ }
});
}
diff --git a/console/src/render.test.ts b/console/src/render.test.ts
index 913e58c..681f33c 100644
--- a/console/src/render.test.ts
+++ b/console/src/render.test.ts
@@ -285,6 +285,15 @@ describe("fleetConfigHtml", () => {
);
});
+ it("always offers the + New fleet deploy action (ADR #83 slice 5, 7.2)", () => {
+ expect(fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca")).toContain(
+ 'data-action="new-fleet"',
+ );
+ expect(
+ fleetConfigHtml({ path: null, default_cluster: "oab", fleets: [], text: "" }, null),
+ ).toContain('data-action="new-fleet"');
+ });
+
it("renders an unavailable state for null", () => {
expect(fleetConfigHtml(null, null)).toContain("fleet config unavailable");
});
@@ -305,9 +314,10 @@ describe("fleetDetailHeaderHtml", () => {
expect(html).toContain("oab-prod-orca");
});
- it("renders the deploy and debug-drawer entry points disabled (slice 2 scope)", () => {
+ it("wires + Add instance (slice 5) but leaves the Debug drawer disabled (slice 6 scope)", () => {
const html = fleetDetailHeaderHtml("oab-prod-orca");
- expect(html).toContain('data-action="add-instance" disabled');
+ expect(html).toContain('data-action="add-instance"');
+ expect(html).not.toContain('data-action="add-instance" disabled');
expect(html).toContain('data-action="fleet-debug" disabled');
});
diff --git a/console/src/render.ts b/console/src/render.ts
index a59b0e7..afbb248 100644
--- a/console/src/render.ts
+++ b/console/src/render.ts
@@ -247,6 +247,8 @@ export function fleetConfigHtml(
Fleets
${path}
+
+
Select a fleet to manage.
@@ -265,15 +267,16 @@ export function renderFleetConfig(
// ---- Fleet detail screen header (ADR #83 slice 2) -----------------------------
// The breadcrumb + action row shown above the roster once a fleet is selected —
// "← Fleets" returns to the Fleets screen (Part A's drill-down). `[+ Add
-// instance]` and `[⚙]` are the slice 5/6 entry points (deploy, Debug drawer);
-// stubbed disabled here per the ADR's slice-2 scope ("No wiring yet").
+// instance]` is the slice 5 entry point (7.5.2: deploy into this fleet, no new
+// fleet-identity step). `[⚙]` is the slice 6 Debug-drawer entry point —
+// stubbed disabled here, still out of scope.
export function fleetDetailHeaderHtml(fleetName: string): string {
return `