feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)
Câble un canal attachable au flux de sortie d'une tâche de fond (runner infrastructure + commande app-tauri + port/adaptateurs frontend) et le panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un état figé au dernier snapshot. Validations obtenues avant commit : - cargo test -p infrastructure --test background_task_runner : vert - cargo check -p backend -p app-tauri : vert - npx vitest run src/features/workstate/workstate.test.tsx : vert - npm run typecheck : vert - verdict QA #58 : vert Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -71,8 +71,10 @@ import type {
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
WorkStateGateway,
|
||||
BackgroundTaskAttachment,
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
import { unsupportedOnWeb } from "./unsupported";
|
||||
import { normalizeTurnPage } from "../conversationNormalization";
|
||||
import { normalizeProfileModelCatalog } from "../profileCatalog";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
@ -443,6 +445,12 @@ export class HttpWorkStateGateway implements WorkStateGateway {
|
||||
const state = await this.http.invoke<unknown>("get_project_work_state", { projectId });
|
||||
return normalizeProjectWorkState(state);
|
||||
}
|
||||
async attachBackgroundTask(
|
||||
_taskId: string,
|
||||
_onData: (bytes: Uint8Array) => void,
|
||||
): Promise<BackgroundTaskAttachment> {
|
||||
return unsupportedOnWeb("Attaching to a background task live stream");
|
||||
}
|
||||
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||
await this.http.invoke<unknown>("cancel_background_task", { taskId });
|
||||
}
|
||||
|
||||
@ -124,6 +124,7 @@ import type {
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
WorkStateGateway,
|
||||
BackgroundTaskAttachment,
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
||||
@ -2519,18 +2520,76 @@ export class MockPermissionGateway implements PermissionGateway {
|
||||
|
||||
export class MockWorkStateGateway implements WorkStateGateway {
|
||||
private states = new Map<string, ProjectWorkState>();
|
||||
private backgroundTaskStreams = new Map<
|
||||
string,
|
||||
{
|
||||
scrollback: Uint8Array;
|
||||
live: boolean;
|
||||
sinks: Set<(bytes: Uint8Array) => void>;
|
||||
}
|
||||
>();
|
||||
|
||||
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
||||
_setProjectWorkState(projectId: string, state: unknown): void {
|
||||
this.states.set(projectId, normalizeProjectWorkState(state));
|
||||
}
|
||||
|
||||
/** Seeds attach output for a background task (deterministic tests/dev). */
|
||||
_setBackgroundTaskAttachment(
|
||||
taskId: string,
|
||||
attachment: { scrollback?: string | Uint8Array; live?: boolean },
|
||||
): void {
|
||||
const encoder = new TextEncoder();
|
||||
const scrollback =
|
||||
typeof attachment.scrollback === "string"
|
||||
? encoder.encode(attachment.scrollback)
|
||||
: attachment.scrollback ?? new Uint8Array();
|
||||
this.backgroundTaskStreams.set(taskId, {
|
||||
scrollback,
|
||||
live: attachment.live ?? false,
|
||||
sinks: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Emits a live output chunk to current subscribers (deterministic tests/dev). */
|
||||
_emitBackgroundTaskOutput(taskId: string, bytes: string | Uint8Array): void {
|
||||
const stream = this.backgroundTaskStreams.get(taskId);
|
||||
if (!stream) return;
|
||||
const encoder = new TextEncoder();
|
||||
const chunk = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
|
||||
for (const sink of stream.sinks) sink(chunk);
|
||||
}
|
||||
|
||||
_backgroundTaskSubscriberCount(taskId: string): number {
|
||||
return this.backgroundTaskStreams.get(taskId)?.sinks.size ?? 0;
|
||||
}
|
||||
|
||||
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||
return structuredClone(
|
||||
this.states.get(projectId) ?? { agents: [], conversations: [] },
|
||||
);
|
||||
}
|
||||
|
||||
async attachBackgroundTask(
|
||||
taskId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<BackgroundTaskAttachment> {
|
||||
const stream = this.backgroundTaskStreams.get(taskId) ?? {
|
||||
scrollback: new Uint8Array(),
|
||||
live: false,
|
||||
sinks: new Set<(bytes: Uint8Array) => void>(),
|
||||
};
|
||||
if (stream.live) stream.sinks.add(onData);
|
||||
return {
|
||||
taskId,
|
||||
scrollback: Uint8Array.from(stream.scrollback),
|
||||
live: stream.live,
|
||||
detach: () => {
|
||||
stream.sinks.delete(onData);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async cancelBackgroundTask(_taskId: string): Promise<void> {
|
||||
// No-op in the mock; real refresh is driven by domain events.
|
||||
}
|
||||
|
||||
@ -5,18 +5,44 @@
|
||||
* command name; features consume the gateway port through DI.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { ProjectWorkState } from "@/domain";
|
||||
import type { WorkStateGateway } from "@/ports";
|
||||
import type { BackgroundTaskAttachment, WorkStateGateway } from "@/ports";
|
||||
import { normalizeProjectWorkState } from "./workStateNormalization";
|
||||
|
||||
interface AttachBackgroundTaskResponse {
|
||||
taskId: string;
|
||||
scrollback: number[];
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export class TauriWorkStateGateway implements WorkStateGateway {
|
||||
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||
const state = await invoke<unknown>("get_project_work_state", { projectId });
|
||||
return normalizeProjectWorkState(state);
|
||||
}
|
||||
|
||||
async attachBackgroundTask(
|
||||
taskId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<BackgroundTaskAttachment> {
|
||||
const channel = new Channel<number[]>();
|
||||
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||
const res = await invoke<AttachBackgroundTaskResponse>("attach_background_task", {
|
||||
taskId,
|
||||
onOutput: channel,
|
||||
});
|
||||
return {
|
||||
taskId: res.taskId,
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
live: res.live,
|
||||
detach: () => {
|
||||
channel.onmessage = () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||
await invoke<unknown>("cancel_background_task", { taskId });
|
||||
}
|
||||
|
||||
@ -66,6 +66,12 @@ function fixedWorkState(): WorkStateGateway {
|
||||
};
|
||||
return {
|
||||
getProjectWorkState: async () => structuredClone(state),
|
||||
attachBackgroundTask: async (taskId) => ({
|
||||
taskId,
|
||||
scrollback: new Uint8Array(),
|
||||
live: false,
|
||||
detach: () => {},
|
||||
}),
|
||||
cancelBackgroundTask: async () => {},
|
||||
retryBackgroundTask: async () => {},
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* and idle/busy state from the backend read-model, plus the current input queue.
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, useState } from "react";
|
||||
import { Component, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentTicketState,
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
InboxItem,
|
||||
LeafCell,
|
||||
} from "@/domain";
|
||||
import type { BackgroundTaskAttachment } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { leaves } from "@/features/layout/layout";
|
||||
import { useLayout } from "@/features/layout/useLayout";
|
||||
@ -337,11 +338,36 @@ function BackgroundTaskRow({
|
||||
}) {
|
||||
const { workState } = useGateways();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [liveOpen, setLiveOpen] = useState(false);
|
||||
const [liveOutput, setLiveOutput] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [liveBusy, setLiveBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
|
||||
const attachSeqRef = useRef(0);
|
||||
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
|
||||
const canCancel = task.status === "running" || task.status === "pending";
|
||||
const canRetry = task.status === "failed" || task.status === "cancelled";
|
||||
const canAttachLive = canCancel;
|
||||
|
||||
function detachLive(): void {
|
||||
attachSeqRef.current += 1;
|
||||
attachmentRef.current?.detach();
|
||||
attachmentRef.current = null;
|
||||
setLiveOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
attachSeqRef.current += 1;
|
||||
attachmentRef.current?.detach();
|
||||
attachmentRef.current = null;
|
||||
};
|
||||
}, [task.taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAttachLive && attachmentRef.current) detachLive();
|
||||
}, [canAttachLive]);
|
||||
|
||||
async function runAction(
|
||||
action: (taskId: string) => Promise<void>,
|
||||
@ -358,6 +384,36 @@ function BackgroundTaskRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLive(): Promise<void> {
|
||||
if (liveOpen || attachmentRef.current) {
|
||||
detachLive();
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = attachSeqRef.current + 1;
|
||||
attachSeqRef.current = seq;
|
||||
setLiveBusy(true);
|
||||
setMessage(null);
|
||||
setLiveOutput("");
|
||||
try {
|
||||
const decoder = new TextDecoder();
|
||||
const attachment = await workState.attachBackgroundTask(task.taskId, (bytes) => {
|
||||
setLiveOutput((prev) => prev + decoder.decode(bytes));
|
||||
});
|
||||
if (seq !== attachSeqRef.current) {
|
||||
attachment.detach();
|
||||
return;
|
||||
}
|
||||
attachmentRef.current = attachment;
|
||||
setLiveOutput(decoder.decode(attachment.scrollback));
|
||||
setLiveOpen(true);
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (seq === attachSeqRef.current) setLiveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="min-w-0 text-xs text-muted">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
@ -386,6 +442,15 @@ function BackgroundTaskRow({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!canAttachLive || liveBusy}
|
||||
loading={liveBusy}
|
||||
onClick={() => void toggleLive()}
|
||||
>
|
||||
{liveOpen ? "Detach" : "Live"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -436,6 +501,14 @@ function BackgroundTaskRow({
|
||||
{task.stderrTail ? `stderr\n${task.stderrTail}` : ""}
|
||||
</pre>
|
||||
)}
|
||||
{liveOpen && (
|
||||
<pre
|
||||
aria-label={`task ${shortTicket(task.taskId)} live output`}
|
||||
className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-border bg-canvas p-2 text-[11px] text-muted"
|
||||
>
|
||||
{liveOutput || "No live output yet."}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@ -336,6 +336,64 @@ describe("ProjectWorkStatePanel", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("attaches, repaints, streams, and detaches live background task output", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-live-task",
|
||||
name: "Runner",
|
||||
profileId: "codex",
|
||||
busy: { state: "idle" },
|
||||
backgroundTasks: [
|
||||
{
|
||||
taskId: "task-live-output",
|
||||
kind: "command",
|
||||
state: "running",
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
workState._setBackgroundTaskAttachment("task-live-output", {
|
||||
scrollback: "already printed\n",
|
||||
live: true,
|
||||
});
|
||||
const attachSpy = vi.spyOn(workState, "attachBackgroundTask");
|
||||
|
||||
const view = renderPanel(workState);
|
||||
const list = await screen.findByLabelText("Runner background tasks");
|
||||
|
||||
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
|
||||
|
||||
expect(attachSpy).toHaveBeenCalledWith("task-live-output", expect.any(Function));
|
||||
expect(
|
||||
(await within(list).findByLabelText("task task-liv live output")).textContent,
|
||||
).toContain("already printed");
|
||||
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
|
||||
|
||||
workState._emitBackgroundTaskOutput("task-live-output", "new line\n");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(list).getByLabelText("task task-liv live output").textContent,
|
||||
).toContain("new line"),
|
||||
);
|
||||
|
||||
fireEvent.click(within(list).getByRole("button", { name: "Detach" }));
|
||||
await waitFor(() =>
|
||||
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0),
|
||||
);
|
||||
expect(within(list).queryByLabelText("task task-liv live output")).toBeNull();
|
||||
|
||||
fireEvent.click(within(list).getByRole("button", { name: "Live" }));
|
||||
await within(list).findByLabelText("task task-liv live output");
|
||||
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(1);
|
||||
view.unmount();
|
||||
expect(workState._backgroundTaskSubscriberCount("task-live-output")).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a legacy agent without tickets", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
|
||||
@ -388,6 +388,18 @@ export interface ReattachResult {
|
||||
scrollback: Uint8Array;
|
||||
}
|
||||
|
||||
/** A UI subscription attached to one background task output stream. */
|
||||
export interface BackgroundTaskAttachment {
|
||||
/** Attached task id, echoed by the backend. */
|
||||
taskId: string;
|
||||
/** Retained bytes to repaint immediately before live chunks arrive. */
|
||||
scrollback: Uint8Array;
|
||||
/** Whether subsequent live bytes are expected on the supplied callback. */
|
||||
live: boolean;
|
||||
/** Detaches the local UI subscriber without cancelling the task. */
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
/** Projects: create/open/close/list (L2). */
|
||||
export interface ProjectGateway {
|
||||
/** Lists the projects known to the registry. */
|
||||
@ -932,6 +944,15 @@ export interface PermissionGateway {
|
||||
export interface WorkStateGateway {
|
||||
/** Reads the current per-agent live/offline and idle/busy state for a project. */
|
||||
getProjectWorkState(projectId: string): Promise<ProjectWorkState>;
|
||||
/**
|
||||
* Attaches a UI output subscriber to a background task. Running tasks replay
|
||||
* PTY scrollback and then stream live chunks; terminal tasks return only their
|
||||
* persisted output tail.
|
||||
*/
|
||||
attachBackgroundTask(
|
||||
taskId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<BackgroundTaskAttachment>;
|
||||
/**
|
||||
* Cancels a running/pending background task by id. The read-model refreshes
|
||||
* through the `backgroundTaskChanged` domain event.
|
||||
|
||||
Reference in New Issue
Block a user