mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-11 19:09:20 +00:00
feat(studio): editable agent queue and a durable run log
The queue was a promise chain, so a waiting run could only be watched. It is now an explicit list: queued runs can be moved sooner or later, dropped before they start, and a run already going can be stopped (the tray reveals those controls on hover). Every settled run is appended to <project>/.hyperframes/agent-runs.jsonl — when it ran, which element, the instruction, how long it took and what the agent reported back. Studio reads that log when a project is first listed, so the history survives a server restart instead of living only in memory.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename } from "node:path";
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
/** Which harness the command belongs to — drives the icon Studio shows. */
|
||||
export type AgentKind = "claude" | "codex" | "hermes" | "openclaw" | "custom";
|
||||
@@ -20,7 +21,7 @@ export interface AgentJob {
|
||||
/** Element or clip the instruction targets, for the run list. */
|
||||
target: string;
|
||||
instruction: string;
|
||||
status: "queued" | "running" | "done" | "failed";
|
||||
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
/** Latest thing the agent did — the tool call or line it is on right now. */
|
||||
activity: string;
|
||||
message?: string;
|
||||
@@ -28,27 +29,182 @@ export interface AgentJob {
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
/** Everything needed to start a queued job once its turn comes. */
|
||||
interface PendingRun {
|
||||
job: AgentJob;
|
||||
agent: AgentCommand;
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs live on the server, not in the tab: a reload re-reads them from here, so
|
||||
* in-flight work is never lost behind a refresh. Per project the queue is
|
||||
* serial — two agents rewriting the same composition would clobber each other.
|
||||
*
|
||||
* The waiting jobs are an explicit array rather than a promise chain, because
|
||||
* the queue is editable: entries can be reordered or dropped before they start.
|
||||
*/
|
||||
const jobsByProject = new Map<string, AgentJob[]>();
|
||||
const queueByProject = new Map<string, Promise<void>>();
|
||||
const pendingByProject = new Map<string, PendingRun[]>();
|
||||
const runningByProject = new Map<string, ChildProcess>();
|
||||
|
||||
const MAX_JOBS_PER_PROJECT = 24;
|
||||
/** Where a project's run history lives, next to the rest of Studio's state. */
|
||||
const RUN_LOG_PATH = join(".hyperframes", "agent-runs.jsonl");
|
||||
const HYDRATED_PROJECTS = new Set<string>();
|
||||
const AGENT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export function listAgentJobs(projectId: string): AgentJob[] {
|
||||
/**
|
||||
* Append a settled run to the project's log. The tray only keeps the recent
|
||||
* ones in memory; this file is the durable record of what was asked for, on
|
||||
* which element, and how it went — one JSON object per line, so it is greppable
|
||||
* by hand and re-readable by Studio.
|
||||
*/
|
||||
function appendRunLog(projectDir: string, job: AgentJob): void {
|
||||
const file = join(projectDir, RUN_LOG_PATH);
|
||||
try {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
appendFileSync(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
at: new Date(job.startedAt).toISOString(),
|
||||
target: job.target,
|
||||
instruction: job.instruction,
|
||||
status: job.status,
|
||||
seconds: Math.round(((job.endedAt ?? Date.now()) - job.startedAt) / 1000),
|
||||
agent: job.label,
|
||||
result: job.message ?? "",
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// A read-only project directory must not take the run down with it.
|
||||
}
|
||||
}
|
||||
|
||||
function isLoggedRun(value: unknown): value is {
|
||||
at: string;
|
||||
target: string;
|
||||
instruction: string;
|
||||
status: AgentJob["status"];
|
||||
seconds: number;
|
||||
agent: string;
|
||||
result: string;
|
||||
} {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as { instruction?: unknown }).instruction === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-seat past runs after a server restart so the history survives it. */
|
||||
function hydrateFromRunLog(projectId: string, projectDir: string): void {
|
||||
if (HYDRATED_PROJECTS.has(projectId)) return;
|
||||
HYDRATED_PROJECTS.add(projectId);
|
||||
|
||||
const file = join(projectDir, RUN_LOG_PATH);
|
||||
if (!existsSync(file)) return;
|
||||
try {
|
||||
const lines = readFileSync(file, "utf-8").trim().split("\n").slice(-MAX_JOBS_PER_PROJECT);
|
||||
const restored = lines.flatMap((line): AgentJob[] => {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!isLoggedRun(entry)) return [];
|
||||
const startedAt = Date.parse(entry.at);
|
||||
return [
|
||||
{
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
kind: "custom",
|
||||
label: entry.agent,
|
||||
target: entry.target,
|
||||
instruction: entry.instruction,
|
||||
// Anything that was mid-flight when the server stopped is over now.
|
||||
status: entry.status === "done" ? "done" : entry.status,
|
||||
activity: "",
|
||||
message: entry.result || undefined,
|
||||
startedAt,
|
||||
endedAt: startedAt + entry.seconds * 1000,
|
||||
},
|
||||
];
|
||||
});
|
||||
jobsByProject.set(projectId, [...restored, ...(jobsByProject.get(projectId) ?? [])]);
|
||||
} catch {
|
||||
// A corrupt log is history, not state — start the tray empty instead.
|
||||
}
|
||||
}
|
||||
|
||||
export function listAgentJobs(projectId: string, projectDir?: string): AgentJob[] {
|
||||
if (projectDir) hydrateFromRunLog(projectId, projectDir);
|
||||
return [...(jobsByProject.get(projectId) ?? [])].reverse();
|
||||
}
|
||||
|
||||
function isActive(job: AgentJob): boolean {
|
||||
return job.status === "queued" || job.status === "running";
|
||||
}
|
||||
|
||||
export function clearFinishedAgentJobs(projectId: string): void {
|
||||
jobsByProject.set(projectId, (jobsByProject.get(projectId) ?? []).filter(isActive));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a run. A queued one never starts; a running one is killed, which is the
|
||||
* only way to stop an agent that is off doing the wrong thing.
|
||||
*/
|
||||
export function cancelAgentJob(projectId: string, jobId: string, dir?: string): AgentJob | null {
|
||||
const job = (jobsByProject.get(projectId) ?? []).find((candidate) => candidate.id === jobId);
|
||||
if (!job || !isActive(job)) return null;
|
||||
|
||||
if (job.status === "queued") {
|
||||
pendingByProject.set(
|
||||
projectId,
|
||||
(pendingByProject.get(projectId) ?? []).filter((pending) => pending.job.id !== jobId),
|
||||
);
|
||||
job.status = "cancelled";
|
||||
job.message = "Cancelled before it started.";
|
||||
job.endedAt = Date.now();
|
||||
if (dir) appendRunLog(dir, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
// Running: mark first so the close handler doesn't overwrite the reason.
|
||||
job.status = "cancelled";
|
||||
job.message = "Stopped mid-run.";
|
||||
runningByProject.get(projectId)?.kill("SIGTERM");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a queued run to a new slot. Positions index the queue itself (0 is next
|
||||
* up), so the caller never has to reason about finished or running entries.
|
||||
*/
|
||||
export function moveAgentJob(projectId: string, jobId: string, toIndex: number): boolean {
|
||||
const pending = pendingByProject.get(projectId) ?? [];
|
||||
const from = pending.findIndex((entry) => entry.job.id === jobId);
|
||||
if (from === -1) return false;
|
||||
|
||||
const to = Math.max(0, Math.min(pending.length - 1, toIndex));
|
||||
if (to === from) return true;
|
||||
|
||||
const [entry] = pending.splice(from, 1);
|
||||
pending.splice(to, 0, entry!);
|
||||
pendingByProject.set(projectId, pending);
|
||||
|
||||
// The visible list is newest-first; keep it consistent with the run order by
|
||||
// re-seating the queued jobs in their new sequence.
|
||||
const jobs = jobsByProject.get(projectId) ?? [];
|
||||
jobsByProject.set(
|
||||
projectId,
|
||||
jobs.filter((job) => job.status === "queued" || job.status === "running"),
|
||||
);
|
||||
const queuedSlots = jobs.flatMap((job, index) => (job.status === "queued" ? [index] : []));
|
||||
const reordered = [...pending].reverse().map((item) => item.job);
|
||||
queuedSlots.forEach((slot, index) => {
|
||||
jobs[slot] = reordered[index]!;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Compact "what is it doing right now" line from one stdout chunk. */
|
||||
@@ -104,11 +260,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
function runAgent(projectId: string, { job, agent, prompt, cwd }: PendingRun): Promise<void> {
|
||||
const settle = (resolve: () => void) => {
|
||||
appendRunLog(cwd, job);
|
||||
resolve();
|
||||
};
|
||||
return new Promise<void>((resolve) => {
|
||||
job.status = "running";
|
||||
job.activity = "Starting…";
|
||||
const child = spawn(agent.command, agent.args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
||||
runningByProject.set(projectId, child);
|
||||
let output = "";
|
||||
let pending = "";
|
||||
|
||||
@@ -130,13 +291,21 @@ function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: strin
|
||||
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
runningByProject.delete(projectId);
|
||||
job.status = "failed";
|
||||
job.message = err.message;
|
||||
job.endedAt = Date.now();
|
||||
resolve();
|
||||
settle(resolve);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
runningByProject.delete(projectId);
|
||||
if (job.status === "cancelled") {
|
||||
job.activity = "";
|
||||
job.endedAt = Date.now();
|
||||
settle(resolve);
|
||||
return;
|
||||
}
|
||||
const failed = code !== 0;
|
||||
job.status = failed ? "failed" : "done";
|
||||
job.message =
|
||||
@@ -146,7 +315,7 @@ function runAgent(job: AgentJob, agent: AgentCommand, prompt: string, cwd: strin
|
||||
: (readResultMessage(agent.kind, output) ?? `${agent.label} finished.`));
|
||||
job.activity = "";
|
||||
job.endedAt = Date.now();
|
||||
resolve();
|
||||
settle(resolve);
|
||||
});
|
||||
|
||||
child.stdin.end(prompt);
|
||||
@@ -181,11 +350,21 @@ export function enqueueAgentJob(opts: {
|
||||
jobs.push(job);
|
||||
jobsByProject.set(opts.projectId, jobs.slice(-MAX_JOBS_PER_PROJECT));
|
||||
|
||||
const tail = queueByProject.get(opts.projectId) ?? Promise.resolve();
|
||||
queueByProject.set(
|
||||
opts.projectId,
|
||||
tail.then(() => runAgent(job, opts.agent, opts.prompt, opts.projectDir)),
|
||||
);
|
||||
const pending = pendingByProject.get(opts.projectId) ?? [];
|
||||
pending.push({ job, agent: opts.agent, prompt: opts.prompt, cwd: opts.projectDir });
|
||||
pendingByProject.set(opts.projectId, pending);
|
||||
void pump(opts.projectId);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Start the next queued run for a project, one at a time. */
|
||||
async function pump(projectId: string): Promise<void> {
|
||||
if (runningByProject.has(projectId)) return;
|
||||
const next = (pendingByProject.get(projectId) ?? []).shift();
|
||||
if (!next) return;
|
||||
if (next.job.status !== "queued") return pump(projectId);
|
||||
|
||||
await runAgent(projectId, next);
|
||||
await pump(projectId);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,28 @@ function createFakeAgent(): string {
|
||||
return script;
|
||||
}
|
||||
|
||||
/** A fake agent that stays alive long enough to reorder or stop the queue. */
|
||||
function createSlowAgent(): string {
|
||||
const dir = createProjectDir();
|
||||
const script = join(dir, "slow-agent.mjs");
|
||||
writeFileSync(
|
||||
script,
|
||||
[
|
||||
"import { appendFileSync } from 'node:fs';",
|
||||
"let prompt = '';",
|
||||
"process.stdin.on('data', (c) => (prompt += c));",
|
||||
"process.stdin.on('end', () => {",
|
||||
" setTimeout(() => {",
|
||||
" appendFileSync('edited.txt', prompt + '\\n');",
|
||||
" console.log('agent done');",
|
||||
" }, 400);",
|
||||
"});",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
return script;
|
||||
}
|
||||
|
||||
function createAdapter(projectDir: string): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
@@ -80,10 +102,18 @@ async function readJobs(app: Hono, projectId: string): Promise<AgentJob[]> {
|
||||
return ((await res.json()) as { jobs: AgentJob[] }).jobs;
|
||||
}
|
||||
|
||||
async function waitFor(check: () => Promise<boolean>): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (await check()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("condition never met");
|
||||
}
|
||||
|
||||
async function waitForIdle(app: Hono, projectId: string): Promise<AgentJob[]> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const jobs = await readJobs(app, projectId);
|
||||
if (jobs.every((job) => job.status === "done" || job.status === "failed")) return jobs;
|
||||
if (jobs.every((job) => job.status !== "queued" && job.status !== "running")) return jobs;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("jobs never settled");
|
||||
@@ -176,7 +206,8 @@ describe("registerAgentRoutes", () => {
|
||||
const res = await enqueue(app, "p1", "make the title red");
|
||||
expect(res.status).toBe(200);
|
||||
const { job } = (await res.json()) as { job: AgentJob };
|
||||
expect(job.status).toBe("queued");
|
||||
// An idle queue starts the run immediately; a busy one leaves it waiting.
|
||||
expect(["queued", "running"]).toContain(job.status);
|
||||
expect(job.instruction).toBe("make the title red");
|
||||
expect(job.target).toBe("#title");
|
||||
|
||||
@@ -207,6 +238,96 @@ describe("registerAgentRoutes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reorders and removes waiting runs", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createSlowAgent()}`;
|
||||
const app = createApp(projectDir);
|
||||
|
||||
await enqueue(app, "q1", "first");
|
||||
const second = (await (await enqueue(app, "q1", "second")).json()) as { job: AgentJob };
|
||||
const third = (await (await enqueue(app, "q1", "third")).json()) as { job: AgentJob };
|
||||
|
||||
// "third" jumps the queue ahead of "second", then "second" is dropped.
|
||||
const moved = await app.request(`/projects/q1/agent/jobs/${third.job.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ position: 0 }),
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
|
||||
const removed = await app.request(`/projects/q1/agent/jobs/${second.job.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(removed.status).toBe(200);
|
||||
expect(
|
||||
((await removed.json()) as { jobs: AgentJob[] }).jobs.find((j) => j.id === second.job.id)
|
||||
?.status,
|
||||
).toBe("cancelled");
|
||||
|
||||
const settled = await waitForIdle(app, "q1");
|
||||
const ran = settled
|
||||
.filter((job) => job.status === "done")
|
||||
.map((job) => job.instruction)
|
||||
.reverse();
|
||||
expect(ran).toEqual(["first", "third"]);
|
||||
});
|
||||
|
||||
it("stops a run that is already going", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createSlowAgent()}`;
|
||||
const app = createApp(projectDir);
|
||||
|
||||
const started = (await (await enqueue(app, "q2", "long one")).json()) as { job: AgentJob };
|
||||
await waitFor(async () => (await readJobs(app, "q2"))[0]?.status === "running");
|
||||
|
||||
const res = await app.request(`/projects/q2/agent/jobs/${started.job.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const settled = await waitForIdle(app, "q2");
|
||||
expect(settled[0]?.status).toBe("cancelled");
|
||||
expect(settled[0]?.message).toBe("Stopped mid-run.");
|
||||
});
|
||||
|
||||
it("refuses to touch a run that already finished", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
|
||||
const app = createApp(projectDir);
|
||||
const { job } = (await (await enqueue(app, "q3", "quick")).json()) as { job: AgentJob };
|
||||
await waitForIdle(app, "q3");
|
||||
|
||||
expect(
|
||||
(await app.request(`/projects/q3/agent/jobs/${job.id}`, { method: "DELETE" })).status,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
it("keeps a readable run log per project and reads it back after a restart", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
|
||||
const app = createApp(projectDir);
|
||||
|
||||
await enqueue(app, "q4", "log this run");
|
||||
await waitForIdle(app, "q4");
|
||||
|
||||
const logged = readFileSync(join(projectDir, ".hyperframes", "agent-runs.jsonl"), "utf-8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(logged).toHaveLength(1);
|
||||
expect(logged[0]).toMatchObject({
|
||||
target: "#title",
|
||||
instruction: "log this run",
|
||||
status: "done",
|
||||
agent: expect.stringContaining("node") as unknown as string,
|
||||
});
|
||||
|
||||
// A fresh project id stands in for a restarted server: same directory, no
|
||||
// in-memory jobs, history still shows up.
|
||||
const restored = await readJobs(createApp(projectDir), "q4-after-restart");
|
||||
expect(restored.map((job) => job.instruction)).toEqual(["log this run"]);
|
||||
expect(restored[0]?.status).toBe("done");
|
||||
});
|
||||
|
||||
it("clears finished jobs only", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
process.env.HYPERFRAMES_AGENT_CMD = `${process.execPath} ${createFakeAgent()}`;
|
||||
|
||||
@@ -4,9 +4,11 @@ import { delimiter, extname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import {
|
||||
cancelAgentJob,
|
||||
clearFinishedAgentJobs,
|
||||
enqueueAgentJob,
|
||||
listAgentJobs,
|
||||
moveAgentJob,
|
||||
type AgentCommand,
|
||||
} from "../helpers/agentJobs.js";
|
||||
|
||||
@@ -117,7 +119,7 @@ export function registerAgentRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
label: agent?.label ?? null,
|
||||
kind: agent?.kind ?? null,
|
||||
iconUrl: resolveAgentIconPath() ? `/api/projects/${project.id}/agent/icon` : null,
|
||||
jobs: listAgentJobs(project.id),
|
||||
jobs: listAgentJobs(project.id, project.dir),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,10 +168,34 @@ export function registerAgentRoutes(api: Hono, adapter: StudioApiAdapter): void
|
||||
return c.json({ job });
|
||||
});
|
||||
|
||||
// Reorder a waiting run. `position` indexes the queue (0 is next up).
|
||||
api.patch("/projects/:id/agent/jobs/:jobId", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as { position?: unknown } | null;
|
||||
const position = typeof body?.position === "number" ? Math.trunc(body.position) : null;
|
||||
if (position === null) return c.json({ error: "position required" }, 400);
|
||||
|
||||
if (!moveAgentJob(project.id, c.req.param("jobId"), position)) {
|
||||
return c.json({ error: "job is not queued" }, 409);
|
||||
}
|
||||
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
|
||||
});
|
||||
|
||||
api.delete("/projects/:id/agent/jobs/:jobId", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
if (!cancelAgentJob(project.id, c.req.param("jobId"), project.dir)) {
|
||||
return c.json({ error: "job already finished" }, 409);
|
||||
}
|
||||
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
|
||||
});
|
||||
|
||||
api.delete("/projects/:id/agent/jobs", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
clearFinishedAgentJobs(project.id);
|
||||
return c.json({ jobs: listAgentJobs(project.id) });
|
||||
return c.json({ jobs: listAgentJobs(project.id, project.dir) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,18 +30,31 @@ function renderTray(
|
||||
jobs: AgentJob[],
|
||||
onClearFinished = vi.fn(),
|
||||
agentIconUrl: string | null = null,
|
||||
handlers: {
|
||||
onMoveJob?: (id: string, position: number) => void;
|
||||
onCancelJob?: (id: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AgentRunTray jobs={jobs} agentIconUrl={agentIconUrl} onClearFinished={onClearFinished} />,
|
||||
<AgentRunTray
|
||||
jobs={jobs}
|
||||
agentIconUrl={agentIconUrl}
|
||||
onClearFinished={onClearFinished}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root, onClearFinished };
|
||||
}
|
||||
|
||||
function buttonLabelled(host: HTMLElement, label: string): HTMLButtonElement[] {
|
||||
return [...host.querySelectorAll("button")].filter((b) => b.getAttribute("aria-label") === label);
|
||||
}
|
||||
|
||||
describe("AgentRunTray", () => {
|
||||
it("renders nothing without runs", () => {
|
||||
const { host, root } = renderTray([]);
|
||||
@@ -94,6 +107,58 @@ describe("AgentRunTray", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue editing", () => {
|
||||
it("moves a waiting run toward the front of the queue", () => {
|
||||
const onMoveJob = vi.fn();
|
||||
// Newest-first rows: "third" is the last in line, "second" ahead of it.
|
||||
const { host, root } = renderTray(
|
||||
[
|
||||
job({ id: "third", status: "queued", instruction: "third" }),
|
||||
job({ id: "second", status: "queued", instruction: "second" }),
|
||||
job({ id: "first", status: "running", instruction: "first" }),
|
||||
],
|
||||
vi.fn(),
|
||||
null,
|
||||
{ onMoveJob },
|
||||
);
|
||||
|
||||
const [thirdSooner, secondSooner] = buttonLabelled(host, "Run sooner");
|
||||
// "second" is already next up, so it cannot move any sooner.
|
||||
expect(secondSooner?.disabled).toBe(true);
|
||||
act(() => thirdSooner?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onMoveJob).toHaveBeenCalledWith("third", 0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("removes a queued run and stops a running one", () => {
|
||||
const onCancelJob = vi.fn();
|
||||
const { host, root } = renderTray(
|
||||
[job({ id: "waiting", status: "queued" }), job({ id: "going", status: "running" })],
|
||||
vi.fn(),
|
||||
null,
|
||||
{ onCancelJob },
|
||||
);
|
||||
|
||||
expect(buttonLabelled(host, "Remove from queue")).toHaveLength(1);
|
||||
const [stop] = buttonLabelled(host, "Stop this run");
|
||||
act(() => stop?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onCancelJob).toHaveBeenCalledWith("going");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("offers no queue controls on finished runs", () => {
|
||||
const { host, root } = renderTray(
|
||||
[job({ status: "done", endedAt: Date.now() })],
|
||||
vi.fn(),
|
||||
null,
|
||||
{ onMoveJob: vi.fn(), onCancelJob: vi.fn() },
|
||||
);
|
||||
expect(buttonLabelled(host, "Stop this run")).toHaveLength(0);
|
||||
expect(buttonLabelled(host, "Run sooner")).toHaveLength(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("harness marks", () => {
|
||||
it("draws a distinct glyph per harness and prefers a supplied icon", () => {
|
||||
const kinds: AgentJob["kind"][] = ["claude", "codex", "hermes", "openclaw", "custom"];
|
||||
|
||||
@@ -6,8 +6,34 @@ const STATUS_DOT: Record<AgentJob["status"], string> = {
|
||||
running: "animate-pulse bg-studio-accent",
|
||||
done: "bg-studio-accent",
|
||||
failed: "bg-red-400",
|
||||
cancelled: "bg-neutral-600",
|
||||
};
|
||||
|
||||
const ROW_BUTTON_CLASS =
|
||||
"rounded-md p-0.5 text-neutral-600 transition-colors duration-150 ease-out " +
|
||||
"hover:bg-neutral-800/70 hover:text-neutral-200 active:scale-[0.96] disabled:opacity-25 " +
|
||||
"disabled:hover:bg-transparent disabled:hover:text-neutral-600";
|
||||
|
||||
function RowIcon({ paths }: { paths: string[] }) {
|
||||
return (
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{paths.map((d) => (
|
||||
<path key={d} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function elapsedLabel(job: AgentJob): string {
|
||||
const end = job.endedAt ?? Date.now();
|
||||
const seconds = Math.max(0, Math.round((end - job.startedAt) / 1000));
|
||||
@@ -17,6 +43,7 @@ function elapsedLabel(job: AgentJob): string {
|
||||
/** Live line for a run: the queue position, what the agent is doing, or how it ended. */
|
||||
function statusLine(job: AgentJob): string {
|
||||
if (job.status === "queued") return "Queued";
|
||||
if (job.status === "cancelled") return job.message ?? "Cancelled";
|
||||
if (job.status === "running") return job.activity || "Working…";
|
||||
return job.message ?? (job.status === "failed" ? "Failed" : "Done");
|
||||
}
|
||||
@@ -31,17 +58,29 @@ export function AgentRunTray({
|
||||
jobs,
|
||||
agentIconUrl = null,
|
||||
onClearFinished,
|
||||
onMoveJob,
|
||||
onCancelJob,
|
||||
}: {
|
||||
jobs: AgentJob[];
|
||||
/** A real logo for the active harness, supplied via HYPERFRAMES_AGENT_ICON. */
|
||||
agentIconUrl?: string | null;
|
||||
onClearFinished: () => void;
|
||||
/** Reorder a waiting run; `position` indexes the queue, 0 is next up. */
|
||||
onMoveJob?: (jobId: string, position: number) => void;
|
||||
/** Drop a queued run, or stop a running one. */
|
||||
onCancelJob?: (jobId: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
if (jobs.length === 0) return null;
|
||||
|
||||
const active = jobs.filter((job) => job.status === "queued" || job.status === "running").length;
|
||||
const finished = jobs.length - active;
|
||||
// The list reads newest-first; the queue runs oldest-first. Map between the
|
||||
// two here so "move up" means "runs sooner" no matter how the rows are shown.
|
||||
const queueOrder = jobs
|
||||
.filter((job) => job.status === "queued")
|
||||
.map((job) => job.id)
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,38 +124,80 @@ export function AgentRunTray({
|
||||
|
||||
{!collapsed && (
|
||||
<ul className="max-h-56 space-y-0.5 overflow-y-auto">
|
||||
{jobs.map((job) => (
|
||||
<li
|
||||
key={job.id}
|
||||
className="rounded-[10px] px-1.5 py-1.5 transition-colors duration-150 ease-out hover:bg-neutral-900/70"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-1.5 shrink-0 rounded-full ${STATUS_DOT[job.status]}`}
|
||||
/>
|
||||
<AgentGlyph kind={job.kind} size={11} iconUrl={agentIconUrl} />
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-none text-neutral-200">
|
||||
{job.instruction}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] leading-none text-neutral-600 tabular-nums">
|
||||
{elapsedLabel(job)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 pl-[18px]">
|
||||
<span className="shrink-0 text-[10px] leading-none text-neutral-600">
|
||||
{job.target}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate text-[10px] leading-none ${
|
||||
job.status === "failed" ? "text-red-400/90" : "text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{statusLine(job)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{jobs.map((job) => {
|
||||
const queueIndex = queueOrder.indexOf(job.id);
|
||||
const canReorder = queueIndex !== -1 && queueOrder.length > 1;
|
||||
return (
|
||||
<li
|
||||
key={job.id}
|
||||
className="group rounded-[10px] px-1.5 py-1.5 transition-colors duration-150 ease-out hover:bg-neutral-900/70"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-1.5 shrink-0 rounded-full ${STATUS_DOT[job.status]}`}
|
||||
/>
|
||||
<AgentGlyph kind={job.kind} size={11} iconUrl={agentIconUrl} />
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] leading-none text-neutral-200">
|
||||
{job.instruction}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] leading-none text-neutral-600 tabular-nums group-hover:hidden">
|
||||
{elapsedLabel(job)}
|
||||
</span>
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<span className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
{canReorder && onMoveJob && (
|
||||
<>
|
||||
<button
|
||||
className={ROW_BUTTON_CLASS}
|
||||
disabled={queueIndex === 0}
|
||||
onClick={() => onMoveJob(job.id, queueIndex - 1)}
|
||||
aria-label="Run sooner"
|
||||
title="Run sooner"
|
||||
>
|
||||
<RowIcon paths={["M8 12.5 V3.5", "M4.5 7 L8 3.5 L11.5 7"]} />
|
||||
</button>
|
||||
<button
|
||||
className={ROW_BUTTON_CLASS}
|
||||
disabled={queueIndex === queueOrder.length - 1}
|
||||
onClick={() => onMoveJob(job.id, queueIndex + 1)}
|
||||
aria-label="Run later"
|
||||
title="Run later"
|
||||
>
|
||||
<RowIcon paths={["M8 3.5 V12.5", "M4.5 9 L8 12.5 L11.5 9"]} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onCancelJob && (
|
||||
<button
|
||||
className={`${ROW_BUTTON_CLASS} hover:text-red-400`}
|
||||
onClick={() => onCancelJob(job.id)}
|
||||
aria-label={
|
||||
job.status === "running" ? "Stop this run" : "Remove from queue"
|
||||
}
|
||||
title={job.status === "running" ? "Stop this run" : "Remove from queue"}
|
||||
>
|
||||
<RowIcon paths={["M4 4 L12 12", "M12 4 L4 12"]} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 pl-[18px]">
|
||||
<span className="shrink-0 text-[10px] leading-none text-neutral-600">
|
||||
{job.target}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate text-[10px] leading-none ${
|
||||
job.status === "failed" ? "text-red-400/90" : "text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{statusLine(job)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -115,8 +115,9 @@ describe("InlineAgentComposer", () => {
|
||||
|
||||
it("names the harness in the header and the element in the field", () => {
|
||||
const { host, root } = renderComposer({ selectionLabel: "Card" });
|
||||
const header = host.querySelector<HTMLElement>("[data-inline-agent-composer]")
|
||||
?.firstElementChild;
|
||||
const header = host.querySelector<HTMLElement>(
|
||||
"[data-inline-agent-composer]",
|
||||
)?.firstElementChild;
|
||||
expect(header?.textContent).toContain("Claude Code");
|
||||
expect(header?.textContent).not.toContain("Card");
|
||||
expect(host.querySelector("textarea")?.placeholder).toBe("Describe a change to Card…");
|
||||
@@ -125,9 +126,9 @@ describe("InlineAgentComposer", () => {
|
||||
|
||||
it("falls back to the element name when no harness is installed", () => {
|
||||
const { host, root } = renderComposer({ runLabel: null, selectionLabel: "Card" });
|
||||
expect(host.querySelector("[data-inline-agent-composer]")?.firstElementChild?.textContent).toContain(
|
||||
"Card",
|
||||
);
|
||||
expect(
|
||||
host.querySelector("[data-inline-agent-composer]")?.firstElementChild?.textContent,
|
||||
).toContain("Card");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
|
||||
@@ -277,6 +277,8 @@ export function InlineAgentComposerHost({
|
||||
jobs={agentJobs}
|
||||
agentIconUrl={agentIconUrl}
|
||||
onClearFinished={actions.clearFinishedAgentJobs}
|
||||
onMoveJob={actions.moveAgentJob}
|
||||
onCancelJob={actions.cancelAgentJob}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AgentJob {
|
||||
label: string;
|
||||
target: string;
|
||||
instruction: string;
|
||||
status: "queued" | "running" | "done" | "failed";
|
||||
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
activity: string;
|
||||
message?: string;
|
||||
startedAt: number;
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface DomEditActionsValue extends Pick<
|
||||
| "handleAgentModalSubmit"
|
||||
| "handleAgentModalRun"
|
||||
| "clearFinishedAgentJobs"
|
||||
| "moveAgentJob"
|
||||
| "cancelAgentJob"
|
||||
| "handleBlockedDomMove"
|
||||
| "handleDomManualDragStart"
|
||||
| "handleDomEditElementDelete"
|
||||
@@ -168,6 +170,8 @@ export function DomEditProvider({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
@@ -256,6 +260,8 @@ export function DomEditProvider({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
@@ -326,6 +332,8 @@ export function DomEditProvider({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
|
||||
@@ -170,6 +170,41 @@ export function useAskAgentModal({
|
||||
[buildPrompt, projectIdRef, resolveSelection, showToast],
|
||||
);
|
||||
|
||||
const patchAgentJobs = useCallback(
|
||||
(path: string, init: RequestInit) => {
|
||||
const pid = projectId ?? projectIdRef.current;
|
||||
if (!pid) return;
|
||||
void fetch(`/api/projects/${pid}/agent/jobs${path}`, init)
|
||||
.then(async (response) => {
|
||||
const data = (await response.json()) as { error?: string; jobs?: AgentJob[] };
|
||||
if (!response.ok) {
|
||||
showToast(data.error ?? "Could not update the queue.", "error");
|
||||
return;
|
||||
}
|
||||
setAgentJobs(data.jobs ?? []);
|
||||
})
|
||||
.catch(() => showToast("Could not update the queue.", "error"));
|
||||
},
|
||||
[projectId, projectIdRef, showToast],
|
||||
);
|
||||
|
||||
/** Reorder a waiting run; `position` indexes the queue, 0 is next up. */
|
||||
const moveAgentJob = useCallback(
|
||||
(jobId: string, position: number) =>
|
||||
patchAgentJobs(`/${jobId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ position }),
|
||||
}),
|
||||
[patchAgentJobs],
|
||||
);
|
||||
|
||||
/** Drop a queued run, or stop one that is already going. */
|
||||
const cancelAgentJob = useCallback(
|
||||
(jobId: string) => patchAgentJobs(`/${jobId}`, { method: "DELETE" }),
|
||||
[patchAgentJobs],
|
||||
);
|
||||
|
||||
const clearFinishedAgentJobs = useCallback(() => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
@@ -207,12 +242,12 @@ export function useAskAgentModal({
|
||||
void refreshAgentState();
|
||||
}, [refreshAgentState]);
|
||||
|
||||
// Poll only while something is queued or running — an idle tray costs nothing.
|
||||
// Fast while something is in flight, slow otherwise — a run can also be
|
||||
// started from another tab or the CLI, and the tray should still notice.
|
||||
const hasActiveJob = agentJobs.some((job) => job.status === "queued" || job.status === "running");
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!hasActiveJob) return;
|
||||
const timer = setInterval(() => void refreshAgentState(), 1200);
|
||||
const timer = setInterval(() => void refreshAgentState(), hasActiveJob ? 1200 : 8000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveJob, refreshAgentState]);
|
||||
|
||||
@@ -253,5 +288,7 @@ export function useAskAgentModal({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ export function useDomEditSession({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
} = useAskAgentModal({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -507,6 +509,8 @@ export function useDomEditSession({
|
||||
handleAgentModalSubmit,
|
||||
handleAgentModalRun,
|
||||
clearFinishedAgentJobs,
|
||||
moveAgentJob,
|
||||
cancelAgentJob,
|
||||
handleBlockedDomMove,
|
||||
handleDomManualDragStart,
|
||||
handleDomEditElementDelete,
|
||||
|
||||
Reference in New Issue
Block a user