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