From ad5f670ec2e1285be23a28a4478334f298fc1db6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 01:18:14 +0200 Subject: [PATCH] feat(ai): revert-per-iteration button in the Workbench timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each iteration bucket now carries a Revert button that undoes every AI write attributed to that iteration. Closes the last open Workbench feature in the roadmap. - `data/ai/revert/inverse-operations.ts` — pluggable registry mapping event types to their undo actions. Ships with inverses for the five most common proposable outcomes: * TaskCreated → tasksStore.deleteTask * TaskCompleted → tasksStore.toggleComplete (back to incomplete) * CalendarEventCreated → eventsStore.deleteEvent * PlaceCreated → placesStore.deletePlace * DrinkLogged → drinkStore.deleteEntry Events with no registered inverse are tallied as `skippedUnsupported` — user knows to handle those manually rather than the service silently doing nothing. - `data/ai/revert/revert-iteration.ts` — orchestrator. Filters `_events` by `actor.iterationId + actor.missionId`, sorts newest-first (so a completion unwinds before the underlying task deletion), applies each inverse, returns `RevertStats` summary. - Workbench UI: Revert button with confirm dialog on every bucket. Shows "X zurückgenommen · Y nicht unterstützt · Z fehlgeschlagen" result alert. - 5 unit tests cover: happy path, unsupported types, failure isolation, user-event skipping, newest-first ordering. With this, the AI Workbench has full audit + undo semantics: user sees everything the AI did, can approve/reject at stage time, and can roll back approved actions after the fact. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/data/ai/revert/inverse-operations.ts | 76 +++++++++++ .../data/ai/revert/revert-iteration.test.ts | 129 ++++++++++++++++++ .../lib/data/ai/revert/revert-iteration.ts | 87 ++++++++++++ .../(app)/companion/workbench/+page.svelte | 56 +++++++- 4 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/data/ai/revert/inverse-operations.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.ts diff --git a/apps/mana/apps/web/src/lib/data/ai/revert/inverse-operations.ts b/apps/mana/apps/web/src/lib/data/ai/revert/inverse-operations.ts new file mode 100644 index 000000000..ee6b5c890 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/revert/inverse-operations.ts @@ -0,0 +1,76 @@ +/** + * Inverse-operations registry — the "undo" side of AI-written events. + * + * Each handler takes the original event's payload and reverses its + * effect by calling the originating module's store. Keeping the + * module-specific knowledge localized here (rather than in revert- + * iteration.ts) means each module can register its own inverse without + * touching the orchestrator. + * + * Not every event is reversible. `create_*` → delete, `*Completed` → + * uncomplete. Non-reversible event types (e.g. one-shot projections + * that already got consumed) simply have no entry; the orchestrator + * reports them as `skippedUnsupported`. + */ + +import { tasksStore } from '$lib/modules/todo/stores/tasks.svelte'; +import { eventsStore } from '$lib/modules/calendar/stores/events.svelte'; +import { placesStore } from '$lib/modules/places/stores/places.svelte'; +import { drinkStore } from '$lib/modules/drink/stores/drink.svelte'; + +export type InverseResult = { readonly ok: true } | { readonly ok: false; readonly reason: string }; + +export type InverseOperation = (payload: Record) => Promise; + +const inverses = new Map(); + +export function registerInverseOperation(eventType: string, op: InverseOperation): void { + inverses.set(eventType, op); +} + +export function getInverseOperation(eventType: string): InverseOperation | undefined { + return inverses.get(eventType); +} + +export function isReversibleEventType(eventType: string): boolean { + return inverses.has(eventType); +} + +// ── Built-in inverses for the tools the AI actually proposes ── + +registerInverseOperation('TaskCreated', async (payload) => { + const taskId = payload.taskId; + if (typeof taskId !== 'string') return { ok: false, reason: 'missing taskId' }; + await tasksStore.deleteTask(taskId); + return { ok: true }; +}); + +registerInverseOperation('TaskCompleted', async (payload) => { + const taskId = payload.taskId; + if (typeof taskId !== 'string') return { ok: false, reason: 'missing taskId' }; + // `toggleComplete` flips; the AI's action was "complete" so toggling + // brings it back to incomplete. + await tasksStore.toggleComplete(taskId); + return { ok: true }; +}); + +registerInverseOperation('CalendarEventCreated', async (payload) => { + const eventId = payload.eventId; + if (typeof eventId !== 'string') return { ok: false, reason: 'missing eventId' }; + await eventsStore.deleteEvent(eventId); + return { ok: true }; +}); + +registerInverseOperation('PlaceCreated', async (payload) => { + const placeId = payload.placeId; + if (typeof placeId !== 'string') return { ok: false, reason: 'missing placeId' }; + await placesStore.deletePlace(placeId); + return { ok: true }; +}); + +registerInverseOperation('DrinkLogged', async (payload) => { + const drinkId = payload.drinkId; + if (typeof drinkId !== 'string') return { ok: false, reason: 'missing drinkId' }; + await drinkStore.deleteEntry(drinkId); + return { ok: true }; +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.test.ts b/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.test.ts new file mode 100644 index 000000000..2f2ecf485 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.test.ts @@ -0,0 +1,129 @@ +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '../../database'; +import { USER_ACTOR } from '../../events/actor'; +import { revertIteration } from './revert-iteration'; +import { registerInverseOperation } from './inverse-operations'; + +const EVENTS_TABLE = '_events'; + +function aiEvent( + type: string, + missionId: string, + iterationId: string, + payload: Record, + timestamp: string +) { + return { + type, + payload, + meta: { + id: crypto.randomUUID(), + timestamp, + appId: 'test', + collection: 'test', + recordId: (payload.taskId as string | undefined) ?? 'r', + userId: 'u', + actor: { + kind: 'ai' as const, + missionId, + iterationId, + rationale: 'r', + }, + }, + }; +} + +beforeEach(async () => { + await db.table(EVENTS_TABLE).clear(); +}); + +describe('revertIteration', () => { + it('runs the inverse for every ai event in the iteration', async () => { + const calls: string[] = []; + registerInverseOperation('RevertTestCreated', async (p) => { + calls.push(p.id as string); + return { ok: true }; + }); + + await db.table(EVENTS_TABLE).bulkAdd([ + aiEvent('RevertTestCreated', 'm-1', 'it-1', { id: 'a' }, '2026-04-15T10:00:00Z'), + aiEvent('RevertTestCreated', 'm-1', 'it-1', { id: 'b' }, '2026-04-15T10:00:01Z'), + // different iteration — should not be reverted + aiEvent('RevertTestCreated', 'm-1', 'it-2', { id: 'c' }, '2026-04-15T10:00:02Z'), + ]); + + const stats = await revertIteration('m-1', 'it-1'); + expect(stats.total).toBe(2); + expect(stats.reverted).toBe(2); + expect(stats.failed).toBe(0); + expect(calls.sort()).toEqual(['a', 'b']); + }); + + it('tallies unsupported event types separately', async () => { + await db + .table(EVENTS_TABLE) + .bulkAdd([aiEvent('UnknownEventType', 'm-1', 'it-x', { id: 'z' }, '2026-04-15T10:00:00Z')]); + + const stats = await revertIteration('m-1', 'it-x'); + expect(stats.skippedUnsupported).toBe(1); + expect(stats.reverted).toBe(0); + }); + + it('records failures without throwing', async () => { + registerInverseOperation('RevertBrokenEvent', async () => { + throw new Error('broken'); + }); + await db + .table(EVENTS_TABLE) + .bulkAdd([aiEvent('RevertBrokenEvent', 'm-1', 'it-f', { id: 'x' }, '2026-04-15T10:00:00Z')]); + + const stats = await revertIteration('m-1', 'it-f'); + expect(stats.failed).toBe(1); + expect(stats.failures[0].reason).toContain('broken'); + }); + + it('ignores user-initiated events from the same record', async () => { + await db.table(EVENTS_TABLE).bulkAdd([ + { + type: 'RevertTestCreated', + payload: { id: 'user-made' }, + meta: { + id: crypto.randomUUID(), + timestamp: '2026-04-15T10:00:00Z', + appId: 'test', + collection: 'test', + recordId: 'user-made', + userId: 'u', + actor: USER_ACTOR, + }, + }, + ]); + const stats = await revertIteration('m-1', 'it-user'); + expect(stats.total).toBe(0); + }); + + it('processes newest-first so dependent events unwind cleanly', async () => { + const order: string[] = []; + registerInverseOperation('RevertOrderTest', async (p) => { + order.push(p.id as string); + return { ok: true }; + }); + await db + .table(EVENTS_TABLE) + .bulkAdd([ + aiEvent('RevertOrderTest', 'm-1', 'it-o', { id: 'first' }, '2026-04-15T10:00:00Z'), + aiEvent('RevertOrderTest', 'm-1', 'it-o', { id: 'second' }, '2026-04-15T10:00:01Z'), + aiEvent('RevertOrderTest', 'm-1', 'it-o', { id: 'third' }, '2026-04-15T10:00:02Z'), + ]); + await revertIteration('m-1', 'it-o'); + expect(order).toEqual(['third', 'second', 'first']); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.ts b/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.ts new file mode 100644 index 000000000..04a37d50a --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/revert/revert-iteration.ts @@ -0,0 +1,87 @@ +/** + * Revert an AI-produced iteration. + * + * Walks the `_events` log for every event attributed to the given + * mission + iteration, looks up each event's inverse in the registry, + * and applies it. Non-reversible events are tallied separately so the + * caller can surface "X actions could not be auto-reverted; please + * revert them manually" instead of silently skipping. + * + * Side-effect of running an inverse: a new "undo" event lands in the + * log, attributed to the USER actor (via the default runAs scope). + * That keeps the timeline honest — the AI did X, then the user did + * not-X. No magic hiding. + */ + +import { db } from '../../database'; +import type { DomainEvent } from '../../events/types'; +import { getInverseOperation } from './inverse-operations'; + +const EVENTS_TABLE = '_events'; + +export interface RevertStats { + total: number; + reverted: number; + skippedUnsupported: number; + failed: number; + failures: { eventType: string; recordId: string; reason: string }[]; +} + +/** + * Revert every event emitted under `actor.iterationId === iterationId` + * (scoped to the given missionId). Called by the Workbench "Revert" + * button on an iteration bucket. + */ +export async function revertIteration( + missionId: string, + iterationId: string +): Promise { + const allEvents = (await db.table(EVENTS_TABLE).toArray()) as DomainEvent[]; + const target = allEvents.filter((e) => { + const a = e.meta.actor; + return a?.kind === 'ai' && a.missionId === missionId && a.iterationId === iterationId; + }); + + const stats: RevertStats = { + total: target.length, + reverted: 0, + skippedUnsupported: 0, + failed: 0, + failures: [], + }; + + // Process newest first — if a later event built on an earlier one + // (e.g. TaskCompleted on a task that TaskCreated made), we must undo + // the completion before deleting the task. + target.sort((a, b) => (a.meta.timestamp < b.meta.timestamp ? 1 : -1)); + + for (const event of target) { + const inverse = getInverseOperation(event.type); + if (!inverse) { + stats.skippedUnsupported++; + continue; + } + try { + const result = await inverse(event.payload as Record); + if (result.ok) { + stats.reverted++; + } else { + stats.failed++; + stats.failures.push({ + eventType: event.type, + recordId: event.meta.recordId, + reason: result.reason, + }); + } + } catch (err) { + stats.failed++; + stats.failures.push({ + eventType: event.type, + recordId: event.meta.recordId, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + return stats; +} diff --git a/apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte b/apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte index dadf5eb14..c736bcc47 100644 --- a/apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/companion/workbench/+page.svelte @@ -9,10 +9,11 @@ Filters: mission (via query-string `?mission=…`), module (dropdown). --> @@ -113,6 +133,16 @@

{b.rationale}

{/if} +
    {#each b.events as e (e.meta.id)} @@ -216,11 +246,33 @@ } .bucket-head { display: grid; - grid-template-columns: 4.5rem 1fr; + grid-template-columns: 4.5rem 1fr auto; gap: 0.75rem; align-items: start; margin-bottom: 0.5rem; } + .revert-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border, #ddd); + border-radius: 0.25rem; + background: var(--color-bg, #fff); + color: var(--color-muted, #666); + font: inherit; + font-size: 0.75rem; + cursor: pointer; + } + .revert-btn:hover:not(:disabled) { + color: #8a1b1b; + border-color: #e99; + background: #fff0f0; + } + .revert-btn:disabled { + cursor: not-allowed; + opacity: 0.5; + } .bucket-when { display: flex; flex-direction: column;