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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp", "crates/oab-mcp", "crates/acp-tunnel"]
members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp", "crates/studio-compose", "crates/oab-mcp", "crates/acp-tunnel"]
resolver = "2"

# Fast profile for the CI-built `oab-mcp` sidecar (desktop.yml). The sidecar is an
Expand Down
47 changes: 47 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,57 @@
<nav class="tabs" id="tabs">
<button class="tab is-active" data-target="log">Activity</button>
<button class="tab" data-target="mcpio">MCP · oab-mcp</button>
<button class="tab" data-target="compose">Compose</button>
<button class="tab" data-target="config">Config</button>
</nav>
<div id="log" class="log pane"></div>
<div id="mcpio" class="log pane mcpio" hidden></div>
<div id="compose" class="pane compose" hidden>
<div class="compose-grid">
<div class="compose-lib">
<div class="compose-lib-head">
<span class="compose-label">Library</span>
<span class="compose-lib-path">template ⊕ overlay + skills</span>
<span class="compose-spacer"></span>
<button type="button" id="compose-save" class="cfg-btn">Save library</button>
</div>
<textarea
id="compose-lib-text"
class="compose-lib-text"
spellcheck="false"
autocomplete="off"
placeholder='{ "templates": {}, "overlays": {}, "skills": { "skills": {} } }'
></textarea>
</div>
<div class="compose-preview-col">
<form id="compose-form" class="compose-form" autocomplete="off">
<label
>Template
<select id="compose-template" class="compose-select"></select>
</label>
<label
>Overlay
<select id="compose-overlay" class="compose-select">
<option value="">— none (bare template) —</option>
</select>
</label>
<div class="compose-actions">
<button type="submit" id="compose-preview-btn">Preview bundle</button>
<span class="compose-status" id="compose-status"></span>
</div>
</form>
<div id="compose-preview" class="compose-preview"></div>
</div>
</div>
<p class="config-hint">
Author a reusable <strong>template</strong> (golden bundle + default
image tag) and per-agent <strong>overlays</strong>; skills are shared
by reference. Compose is deterministic, last-writer-wins per path
(overlay &gt; skills &gt; template), and produces a
<code>{path → bytes}</code> bundle. Preview is local — no deploy. The
provider drivers that land a bundle on a runtime are a later slice.
</p>
</div>
<div id="config" class="pane config" hidden>
<form id="config-form" class="config-form" autocomplete="off">
<label
Expand Down
65 changes: 65 additions & 0 deletions console/src/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { renderPreviewHtml, libraryNames, type BundlePreview, type Library } from "./compose";

const PREVIEW: BundlePreview = {
image_tag: "ghcr.io/openabdev/openab:0.9.0-claude",
digest: "sha256:abc123",
files: [
{ path: ".claude/skills/memory/SKILL.md", text: "# memory\n", bytes: 9, binary: false },
{ path: "CLAUDE.md", text: "# persona\n", bytes: 10, binary: false },
],
};

describe("renderPreviewHtml", () => {
it("shows the image tag, digest and file count", () => {
const html = renderPreviewHtml(PREVIEW);
expect(html).toContain("ghcr.io/openabdev/openab:0.9.0-claude");
expect(html).toContain("sha256:abc123");
// file count
expect(html).toContain("files</span> 2");
});

it("lists each file path with its content", () => {
const html = renderPreviewHtml(PREVIEW);
expect(html).toContain(".claude/skills/memory/SKILL.md");
expect(html).toContain("CLAUDE.md");
expect(html).toContain("# persona\n");
});

it("escapes HTML in paths and content (no injection)", () => {
const html = renderPreviewHtml({
image_tag: "img",
digest: "sha256:x",
files: [{ path: "<evil>.md", text: "<script>alert(1)</script>", bytes: 5, binary: false }],
});
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("&lt;evil&gt;.md");
});

it("does not dump content for a binary file", () => {
const html = renderPreviewHtml({
image_tag: "img",
digest: "sha256:x",
files: [{ path: "blob", text: "�", bytes: 3, binary: true }],
});
expect(html).toContain("binary");
expect(html).toContain("not shown");
});
});

describe("libraryNames", () => {
it("returns sorted template and overlay names", () => {
const lib: Library = {
templates: { zeta: {} as never, alpha: {} as never },
overlays: { orca: {} as never },
skills: { skills: {} },
};
expect(libraryNames(lib)).toEqual({ templates: ["alpha", "zeta"], overlays: ["orca"] });
});

it("tolerates missing maps", () => {
const lib = { skills: { skills: {} } } as unknown as Library;
expect(libraryNames(lib)).toEqual({ templates: [], overlays: [] });
});
});
211 changes: 211 additions & 0 deletions console/src/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Compose tab: author the template/overlay/skills library and preview the
// composed `{path → bytes}` agent bundle (agent-deployment ADR, slice 1).
//
// The library is edited as one JSON document (same "edit the config text, save,
// reload" idiom as fleets.toml / remote.toml) and the preview is rendered
// read-only. Everything real runs through the backend: the library is persisted
// by `compose_library_set`, and the preview is composed by `compose_preview`
// (the pure Rust `studio-compose` seam) — so the browser build, which has no
// backend, disables the panel rather than re-implementing compose in TS.
//
// These types mirror `studio_compose`'s serde shapes 1:1.

export interface Template {
name: string;
image_tag: string;
files: Record<string, string>;
skills: string[];
}
export interface Overlay {
name: string;
image_tag?: string | null;
files: Record<string, string>;
skills: string[];
}
export interface Skill {
files: Record<string, string>;
}
export interface SkillsLibrary {
skills: Record<string, Skill>;
}
export interface Library {
templates: Record<string, Template>;
overlays: Record<string, Overlay>;
skills: SkillsLibrary;
}
export interface FilePreview {
path: string;
text: string;
bytes: number;
binary: boolean;
}
export interface BundlePreview {
image_tag: string;
digest: string;
files: FilePreview[];
}

type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;

function tauriInvoke(): Invoke | null {
const t = (globalThis as { __TAURI__?: { core?: { invoke?: Invoke } } }).__TAURI__;
return t?.core?.invoke ?? null;
}

// Tauri command rejections arrive as plain strings, not Error objects.
function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

/** Render the composed-bundle preview to HTML — a pure view (unit-tested). */
export function renderPreviewHtml(preview: BundlePreview): string {
const rows = preview.files
.map((f) => {
const meta = f.binary ? `${f.bytes} bytes · binary` : `${f.bytes} bytes`;
const body = f.binary
? `<pre class="compose-file-body compose-file-binary">(binary — ${f.bytes} bytes, not shown)</pre>`
: `<pre class="compose-file-body">${escapeHtml(f.text)}</pre>`;
return (
`<details class="compose-file">` +
`<summary><code>${escapeHtml(f.path)}</code><span class="compose-file-meta">${escapeHtml(meta)}</span></summary>` +
body +
`</details>`
);
})
.join("");
const count = preview.files.length;
return (
`<div class="compose-bundle-head">` +
`<div><span class="compose-k">image</span> <code>${escapeHtml(preview.image_tag)}</code></div>` +
`<div><span class="compose-k">digest</span> <code>${escapeHtml(preview.digest)}</code></div>` +
`<div><span class="compose-k">files</span> ${count}</div>` +
`</div>` +
`<div class="compose-files">${rows}</div>`
);
}

/** Names present in the parsed library, sorted — drives the picker `<option>`s. */
export function libraryNames(lib: Library): { templates: string[]; overlays: string[] } {
const keys = (o: Record<string, unknown> | undefined): string[] =>
o ? Object.keys(o).sort() : [];
return { templates: keys(lib.templates), overlays: keys(lib.overlays) };
}

function fillOptions(sel: HTMLSelectElement, names: string[], keepNoneFirst: boolean): void {
const prev = sel.value;
const opts = keepNoneFirst ? ['<option value="">— none (bare template) —</option>'] : [];
for (const n of names) opts.push(`<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`);
sel.innerHTML = opts.join("");
// Preserve the operator's selection across a repopulate if it still exists.
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev;
}

export function initComposeTab(): void {
const text = document.getElementById("compose-lib-text") as HTMLTextAreaElement | null;
const tmplSel = document.getElementById("compose-template") as HTMLSelectElement | null;
const ovlSel = document.getElementById("compose-overlay") as HTMLSelectElement | null;
const form = document.getElementById("compose-form") as HTMLFormElement | null;
const saveBtn = document.getElementById("compose-save") as HTMLButtonElement | null;
const out = document.getElementById("compose-preview");
const statusEl = document.getElementById("compose-status");
if (!text || !tmplSel || !ovlSel || !form) return;

const setStatus = (msg: string, cls = ""): void => {
if (statusEl) {
statusEl.textContent = msg;
statusEl.className = cls ? `compose-status ${cls}` : "compose-status";
}
};

const invoke = tauriInvoke();
if (!invoke) {
setStatus("browser build — compose unavailable");
text.disabled = true;
tmplSel.disabled = true;
ovlSel.disabled = true;
for (const b of form.querySelectorAll("button")) b.disabled = true;
if (saveBtn) saveBtn.disabled = true;
return;
}

// Best-effort: parse the editor text and refresh the pickers so newly authored
// templates/overlays are selectable before a save. Silent on parse error — the
// Preview/Save buttons surface it with a real message.
const syncPickers = (): void => {
try {
const lib = JSON.parse(text.value) as Library;
const { templates, overlays } = libraryNames(lib);
fillOptions(tmplSel, templates, false);
fillOptions(ovlSel, overlays, true);
} catch {
/* leave pickers as-is until the JSON is valid */
}
};

const parseLibrary = (): Library => JSON.parse(text.value) as Library;

invoke<Library>("compose_library_get")
.then((lib) => {
text.value = JSON.stringify(lib, null, 2);
syncPickers();
})
.catch((e) => setStatus(`load failed: ${errText(e)}`, "err"));

text.addEventListener("input", syncPickers);

saveBtn?.addEventListener("click", async () => {
let lib: Library;
try {
lib = parseLibrary();
} catch (e) {
setStatus(`invalid JSON: ${errText(e)}`, "err");
return;
}
saveBtn.disabled = true;
setStatus("saving…");
try {
const saved = await invoke<Library>("compose_library_set", { library: lib });
text.value = JSON.stringify(saved, null, 2);
syncPickers();
setStatus("saved", "ok");
} catch (e) {
setStatus(`save failed: ${errText(e)}`, "err");
} finally {
saveBtn.disabled = false;
}
});

form.addEventListener("submit", async (ev) => {
ev.preventDefault();
let lib: Library;
try {
lib = parseLibrary();
} catch (e) {
setStatus(`invalid JSON: ${errText(e)}`, "err");
return;
}
const template = tmplSel.value;
if (!template) {
setStatus("pick a template to preview", "err");
return;
}
const overlay = ovlSel.value || null;
setStatus("composing…");
try {
const preview = await invoke<BundlePreview>("compose_preview", { library: lib, template, overlay });
if (out) out.innerHTML = renderPreviewHtml(preview);
setStatus(`composed — ${preview.files.length} files`, "ok");
} catch (e) {
if (out) out.innerHTML = "";
setStatus(`compose failed: ${errText(e)}`, "err");
}
});
}
4 changes: 4 additions & 0 deletions console/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defaultSource } from "./source";
import { initConfigTab } from "./config";
import { initComposeTab } from "./compose";
import {
renderRoster,
renderIdentity,
Expand Down Expand Up @@ -754,6 +755,9 @@ async function boot(): Promise<void> {
// Config tab: pin the oab-mcp target (cluster/profile/region → hermetic env);
// on save the backend reloads the core, so refresh the roster after.
initConfigTab({ onSaved: () => void tick() });
// Compose tab: author the template/overlay/skills library + preview the
// composed bundle (agent-deployment ADR, slice 1). Self-contained; no polling.
initComposeTab();
void refreshConfig();
void refreshIdentity();
void refreshRemote();
Expand Down
Loading
Loading