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
19 changes: 14 additions & 5 deletions docs/agent-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ agents. The only product entry point is ordinary desktop startup.
## Normal desktop workflow

Run from the feature worktree with `bin/just desktop`, not a management-only
launcher. This opens **Buzz Foundation** using the ordinary live-development
launcher. The command prepares the pinned agent runtime before starting Tauri;
the first build may take several minutes. Later launches verify and reuse matching
resources, rebuilding missing, stale or corrupt ones. Preparation failure stops
launch rather than opening a desktop that cannot run agents. This opens **Buzz Foundation** using the ordinary live-development
configuration and persistent native settings. Coordinate the native rebuild/relaunch;
quit other Foundation copies first. Saved enabled agents can restore on startup.
Keep imported agents disabled and old Buzz running until an attended handover.
Expand All @@ -27,6 +30,8 @@ During a Create/profile wait, **Close** leaves the native operation running and
exposes the existing cards' recovery Stop. Closing before creation returns skips
automatic profile publication; refresh status and retry on the saved card. Late
completion never closes a subsequently opened dialog.
Create is blocked with an explanation if this app’s runtime is unavailable;
existing agents and profile retry remain intact.
The dev broker and native host must both support this flow. Packaged human
signing remains unavailable.

Expand Down Expand Up @@ -118,9 +123,11 @@ resources. Production has no disposable storage override or preview launch mode.
unavailable capability; no fetch fallback, local storage, signing or runner.
- `bundled/agents/AgentControlPanel.tsx`: compose with `{ control }` independently
of selected community or relay connectivity. It owns only observation and UI
drafts. Its five-second refresh runs while visible/ready; reads coalesce. On
errors it stops polling and exposes explicit Retry. Unmount clears the timer,
not enabled intent or processes.
drafts. Its five-second refresh runs while visible/ready; reads coalesce. A read
rejected specifically because native startup is initializing or its lock is busy
stays pending for at most twenty 250ms waits. Genuine errors or exhausted retries
stop polling and expose explicit Retry; writes are never automatically retried.
Unmount clears the timer, not enabled intent or processes.
- Native host owns persistent state, credential custody, process groups, lock and
duplicate ownership checks, source import validation and sanitized diagnostics.
It must bound IPC operations and reject with deliberately user-facing strings;
Expand Down Expand Up @@ -224,7 +231,9 @@ resources. Production has no disposable storage override or preview launch mode.

## Runtime resources

Build without launching any app or accessing old credentials:
Ordinary `bin/just desktop` and `bin/pnpm tauri build` prepare these resources
automatically. Direct Cargo builds do not run that JavaScript preparation step.
To prepare/build without launching any app or accessing old credentials:

```sh
bin/pnpm install --frozen-lockfile
Expand Down
5 changes: 4 additions & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ need their own validation.
e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port
(default: 1430) or the next available port, allowing parallel browser development.
- `just desktop [args...]`: install locked dependencies and forward arguments to
Tauri, e.g. `just desktop --port 1431 --no-watch`. The desktop adapter consumes
Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter
builds the pinned agent runtime when missing/outdated, or verifies and reuses it.
A preparation failure stops launch; help does not prepare resources.
The desktop adapter consumes
`--port N` or `--port=N` to set both Vite's port and Tauri's development URL;
Tauri's own `--port` is for its static-file server, not Vite. Without this flag,
the existing Tauri configuration is unchanged (port 1430). Desktop requires the
Expand Down
44 changes: 43 additions & 1 deletion scripts/build-agent-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
readFile,
lstat,
access,
constants,
mkdir,
mkdtemp,
copyFile,
Expand Down Expand Up @@ -48,6 +51,46 @@ const target = (await run(join(root, "bin/rustc"), ["-vV"], true)).match(
/^host: (.+)$/m,
)?.[1];
if (!target) throw new Error("Could not resolve pinned Rust target");
const destination = join(root, "src-tauri/resources/agent-runtime");
const filenames = spec.tools.map((name) =>
process.platform === "win32" ? `${name}.exe` : name,
);
async function currentBundle() {
try {
if (!(await lstat(destination)).isDirectory()) return false;
const manifestPath = join(destination, "manifest.json");
const meta = await lstat(manifestPath);
if (!meta.isFile() || meta.size > 16384) return false;
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (
Object.keys(manifest).length !== 4 ||
manifest.version !== 1 ||
manifest.revision !== spec.revision ||
manifest.target !== target ||
Object.keys(manifest.files).length !== filenames.length
)
return false;
for (const filename of filenames) {
const path = join(destination, filename);
if (!(await lstat(path)).isFile()) return false;
await access(path, constants.X_OK);
const hash = createHash("sha256")
.update(await readFile(path))
.digest("hex");
if (manifest.files[filename] !== hash) return false;
}
return true;
} catch {
return false;
}
}
if (await currentBundle()) {
console.log(`Agent runtime ready (${spec.revision}, ${target})`);
process.exit(0);
}
console.log(
"Preparing the agent runtime; the first build can take several minutes.",
);
await mkdir(join(root, "target"), { recursive: true });
const stage = await mkdtemp(join(root, "target/agent-runtime-stage-"));
try {
Expand All @@ -67,7 +110,6 @@ try {
"buzz-cli",
"git-credential-nostr",
]);
const destination = join(root, "src-tauri/resources/agent-runtime");
await mkdir(destination, { recursive: true });
const files = {};
for (const name of spec.tools) {
Expand Down
12 changes: 12 additions & 0 deletions scripts/desktop-dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ for (; index < args.length && args[index] !== "--"; index++) {
}
}

// Prepare resources before Tauri can compile or observe an already-running Vite.
// Its dev-server readiness timeout must not include a cold runtime build.
if (!forwarded.some((arg) => arg === "--help" || arg === "-h")) {
const prepared = spawnSync(
process.execPath,
[fileURLToPath(new URL("./build-agent-runtime.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (prepared.error) console.error(prepared.error.message);
if (prepared.signal) process.kill(process.pid, prepared.signal);
if (prepared.status !== 0) process.exit(prepared.status ?? 1);
}
const config = {};
const icon = worktreeIcon(fileURLToPath(new URL("../", import.meta.url)));
if (icon) config.bundle = { icon: [icon] };
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "pnpm dev:desktop",
"devUrl": "http://localhost:1430",
"beforeBuildCommand": "pnpm build",
"beforeBuildCommand": "node scripts/build-agent-runtime.mjs && pnpm build",
"frontendDist": "../dist"
},
"app": {
Expand Down
12 changes: 10 additions & 2 deletions src/bundled/agents/AgentCreateDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ export function AgentCreateDialog({
state.data?.createAvailable &&
control.create
);
const runtimeBlocked = !state.data?.runtimeAvailable && !saved;
const blocked = busy || state.busy || state.status !== "ready";
const create = async () => {
if (blocked || !available || !control.create) return;
if (blocked || runtimeBlocked || !available || !control.create) return;
setError(undefined);
setBusy(true);
try {
Expand Down Expand Up @@ -129,6 +130,13 @@ export function AgentCreateDialog({
an agent.
</p>
)}
{runtimeBlocked && (
<p role="alert">
This app’s agent runtime is unavailable. Repair or rebuild the
desktop app before creating an agent.
{state.data?.runtimeMessage && ` ${state.data.runtimeMessage}`}
</p>
)}
{busy && (
<p role="status">
You can close this dialog to stop another agent. Saving
Expand Down Expand Up @@ -158,7 +166,7 @@ export function AgentCreateDialog({
<Button
type="submit"
variant="primary"
disabled={blocked || !available}
disabled={blocked || runtimeBlocked || !available}
>
{busy ? "Saving…" : saved ? "Retry profile" : "Create agent"}
</Button>
Expand Down
145 changes: 145 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const disposals: (() => void)[] = [];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
for (const dispose of disposals.splice(0)) dispose();
});
function setup(
Expand Down Expand Up @@ -591,3 +592,147 @@ for (const stage of ["create", "profile"] as const) {
});
}
}

it("blocks creation before native writes when the runtime is missing and preserves the draft for recovery", async () => {
const prepare = vi.fn(async () => ({
id: "created",
pubkey: "cd".repeat(32),
}));
const commit = vi.fn();
const { f, control } = setup("connected", (fixture) => {
fixture.data.createAvailable = true;
fixture.data.runtimeAvailable = false;
fixture.data.runtimeMessage =
"Agent runtime is not packaged; build its resources first";
fixture.host.prepareCreate = prepare;
fixture.host.commitCreate = commit;
});
fireEvent.click(await screen.findByRole("button", { name: "Add agent" }));
const dialog = screen.getByRole("dialog", { name: "Create agent" });
fireEvent.change(within(dialog).getByLabelText("Name"), {
target: { value: "Calvin" },
});
const create = within(dialog).getByRole("button", { name: "Create agent" });
expect(create).toBeDisabled();
expect(within(dialog).getByRole("alert")).toHaveTextContent(
"agent runtime is unavailable",
);
const form = create.closest("form");
if (!form) throw Error("Missing create form");
await act(async () => fireEvent.submit(form));
expect(prepare).not.toHaveBeenCalled();
expect(commit).not.toHaveBeenCalled();
f.data.runtimeAvailable = true;
await act(async () => control.refresh());
expect(within(dialog).getByLabelText("Name")).toHaveValue("Calvin");
expect(create).toBeEnabled();
expect(within(dialog).queryByRole("alert")).toBeNull();
});

it("retries the same saved profile even if the runtime becomes unavailable", async () => {
const prepare = vi.fn(async () => ({
id: "created",
pubkey: "cd".repeat(32),
}));
const commit = vi.fn();
const profile = vi.fn();
vi.spyOn(communityApi, "communityRequest").mockResolvedValue({ auth: [] });
const { f, control } = setup("connected", (fixture) => {
fixture.data.createAvailable = true;
fixture.data.defaultWorkspace = "/fixture/workspace";
fixture.host.prepareCreate = prepare;
fixture.host.commitCreate = commit.mockImplementation(
async (_request, edit) => {
fixture.data.agents.push({
...structuredClone(fixture.agent),
id: "created",
name: edit.name,
enabled: false,
status: "stopped",
profilePending: true,
});
return structuredClone(fixture.data);
},
);
fixture.host.publishProfile = profile
.mockRejectedValueOnce("Synthetic profile failure")
.mockImplementation(async () => {
const created = fixture.data.agents.find(
(agent) => agent.id === "created",
);
if (!created) throw Error("Missing created fixture");
created.profilePending = false;
return structuredClone(fixture.data);
});
});
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "Add agent" }));
const dialog = screen.getByRole("dialog", { name: "Create agent" });
await user.type(within(dialog).getByLabelText("Name"), "Calvin");
await user.click(
within(dialog).getByRole("button", { name: "Create agent" }),
);
await within(dialog).findByText(/Calvin is saved and stopped/);
expect(profile).toHaveBeenCalledExactlyOnceWith("created");
f.data.runtimeAvailable = false;
await act(async () => control.refresh());
const retry = within(dialog).getByRole("button", { name: "Retry profile" });
expect(retry).toBeEnabled();
await user.click(retry);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(prepare).toHaveBeenCalledOnce();
expect(commit).toHaveBeenCalledOnce();
expect(profile.mock.calls).toEqual([["created"], ["created"]]);
});

for (const error of [
"Agent runtime is initializing; retry shortly",
"Another native agent operation is in progress",
]) {
it(`shows agents without manual Retry after a transient native read: ${error}`, async () => {
vi.useFakeTimers();
let snapshot!: ReturnType<typeof vi.spyOn>;
setup("ready", (f) => {
snapshot = vi.spyOn(f.host, "snapshot").mockRejectedValueOnce(error);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText("Reading local agent status…")).toBeVisible();
expect(screen.queryByRole("button", { name: "Retry status" })).toBeNull();
expect(snapshot).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(
screen.getAllByRole("article", { name: "Agent Fixture agent" }),
).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Retry status" })).toBeNull();
expect(snapshot).toHaveBeenCalledTimes(2);
});
}
it("shows Retry after persistent or genuine read failure without hiding the error", async () => {
vi.useFakeTimers();
const { f } = setup("ready", (f) => {
vi.spyOn(f.host, "snapshot").mockRejectedValue(
"Agent runtime is initializing; retry shortly",
);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(screen.getByRole("button", { name: "Retry status" })).toBeVisible();
expect(screen.getByText(/Could not refresh local agents/)).toBeVisible();
expect(f.host.snapshot).toHaveBeenCalledTimes(21);
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(f.host.snapshot).toHaveBeenCalledTimes(21);
vi.mocked(f.host.snapshot).mockRejectedValue("Store is unreadable");
fireEvent.click(screen.getByRole("button", { name: "Retry status" }));
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(f.host.snapshot).toHaveBeenCalledTimes(22);
expect(screen.getByRole("button", { name: "Retry status" })).toBeVisible();
});
Loading
Loading