feat(memory): récolte automatique contrôlée de la mémoire (Lot E1)

Câble la récolte automatique de mémoire de bout en bout, sous contrôle
explicite, du domaine jusqu'à l'UI :

- domain : modèle `memory_harvest` (candidats de récolte, décision) +
  exports `lib.rs`.
- application : use case `memory/harvest` branché dans `memory/mod`,
  `lib.rs`, le cycle de vie d'agent (`agent/lifecycle`) et
  l'orchestrateur (`orchestrator/service`).
- app-tauri : wiring runtime dans `state`.
- frontend : DTO `domain/index` + hook `useMemory`.

Couverture : domain `memory_harvest` (14), application `memory_harvest`
(5) et `orchestrator_service` (récolte, 4), plus patch test-only du mock
gateway `workState` (`mock.test.ts`) et UI `memory.test`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 02:00:05 +02:00
parent 33e65dec74
commit b12081be18
14 changed files with 1155 additions and 20 deletions

View File

@ -12,7 +12,7 @@ import { createMockGateways, MockSystemGateway } from "./index";
const gateways: Gateways = createMockGateways();
describe("createMockGateways", () => {
it("exposes all fourteen gateways", () => {
it("exposes all gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([
"agent",
"embedder",
@ -28,6 +28,7 @@ describe("createMockGateways", () => {
"system",
"template",
"terminal",
"workState",
]);
});
});

View File

@ -91,6 +91,7 @@ export type DomainEvent =
| { type: "layoutChanged"; projectId: string }
| { type: "remoteConnected"; projectId: string }
| { type: "gitStateChanged"; projectId: string }
| { type: "memorySaved"; slug: string }
| {
type: "orchestratorRequestProcessed";
requesterId: string;

View File

@ -18,7 +18,11 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockMemoryGateway, MockEmbedderGateway } from "@/adapters/mock";
import {
MockMemoryGateway,
MockEmbedderGateway,
MockSystemGateway,
} from "@/adapters/mock";
import type { GatewayError } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
@ -26,14 +30,19 @@ import { MemoryPanel } from "./MemoryPanel";
const PROJECT_ID = "proj-memory-test";
function renderMemoryPanel(memory?: MockMemoryGateway) {
function renderMemoryPanel(
memory?: MockMemoryGateway,
system: MockSystemGateway = new MockSystemGateway(),
) {
const mem = memory ?? new MockMemoryGateway();
const gateways = {
memory: mem,
embedder: new MockEmbedderGateway(),
system,
} as unknown as Gateways;
return {
memory: mem,
system,
...render(
<DIProvider gateways={gateways}>
<MemoryPanel projectId={PROJECT_ID} />
@ -240,6 +249,40 @@ describe("MemoryPanel (with MockMemoryGateway)", () => {
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/boom from gateway/i);
});
it("refreshes the index when a memorySaved domain event fires", async () => {
const memory = new MockMemoryGateway();
const system = new MockSystemGateway();
const spy = vi.spyOn(memory, "readIndex");
renderMemoryPanel(memory, system);
await waitForMemoryIdle();
spy.mockClear();
await memory.createMemory({
projectId: PROJECT_ID,
name: "Harvested Note",
description: "created by idea-memory",
type: "project",
content: "harvested body",
});
system.emit({ type: "memorySaved", slug: "harvested-note" });
expect(await screen.findByText("Harvested Note")).toBeTruthy();
expect(spy).toHaveBeenCalled();
});
it("does not refresh the index for unrelated domain events", async () => {
const memory = new MockMemoryGateway();
const system = new MockSystemGateway();
const spy = vi.spyOn(memory, "readIndex");
renderMemoryPanel(memory, system);
await waitForMemoryIdle();
spy.mockClear();
system.emit({ type: "projectCreated", projectId: "other-project" });
await waitFor(() => expect(spy).not.toHaveBeenCalled());
});
});
// ---------------------------------------------------------------------------

View File

@ -46,7 +46,7 @@ function describe(e: unknown): string {
}
export function useMemory(projectId: string): MemoryViewModel {
const { memory } = useGateways();
const { memory, system } = useGateways();
const [entries, setEntries] = useState<MemoryIndexEntry[]>([]);
const [error, setError] = useState<string | null>(null);
@ -69,6 +69,24 @@ export function useMemory(projectId: string): MemoryViewModel {
void refresh();
}, [refresh]);
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system.onDomainEvent((event) => {
if (event.type === "memorySaved") {
void refresh();
}
}).then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system]);
const createMemory = useCallback(
async (input: Omit<CreateMemoryInput, "projectId">) => {
setBusy(true);