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:
2026-07-27 16:48:29 +02:00
parent 6f532d7434
commit b734c237d3
17 changed files with 528 additions and 23 deletions

View File

@ -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 });
}

View File

@ -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.
}

View File

@ -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 });
}

View File

@ -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 () => {},
};

View File

@ -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>
);
}

View File

@ -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, {

View File

@ -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.