feat(ai): revert-per-iteration button in the Workbench timeline

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 01:18:14 +02:00
parent 99efb93816
commit ad5f670ec2
4 changed files with 346 additions and 2 deletions

View file

@ -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<string, unknown>) => Promise<InverseResult>;
const inverses = new Map<string, InverseOperation>();
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 };
});

View file

@ -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<string, unknown>,
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']);
});
});

View file

@ -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<RevertStats> {
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<string, unknown>);
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;
}

View file

@ -9,10 +9,11 @@
Filters: mission (via query-string `?mission=…`), module (dropdown).
-->
<script lang="ts">
import { Sparkle, ArrowLeft, ArrowSquareOut } from '@mana/shared-icons';
import { Sparkle, ArrowLeft, ArrowSquareOut, ArrowCounterClockwise } from '@mana/shared-icons';
import { page } from '$app/stores';
import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries';
import { useMissions } from '$lib/data/ai/missions/queries';
import { revertIteration } from '$lib/data/ai/revert/revert-iteration';
import type { DomainEvent } from '$lib/data/events/types';
const missionId = $derived($page.url.searchParams.get('mission') ?? undefined);
@ -57,6 +58,25 @@
month: 'short',
});
}
let revertingKey = $state<string | null>(null);
async function handleRevert(bucketKey: string, missionId: string, iterationId: string) {
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
revertingKey = bucketKey;
try {
const stats = await revertIteration(missionId, iterationId);
const parts = [`${stats.reverted} zurückgenommen`];
if (stats.skippedUnsupported > 0) parts.push(`${stats.skippedUnsupported} nicht unterstützt`);
if (stats.failed > 0) parts.push(`${stats.failed} fehlgeschlagen`);
alert(parts.join(' · '));
} catch (err) {
console.error('[workbench] revert failed:', err);
alert('Revert fehlgeschlagen — siehe Console.');
} finally {
revertingKey = null;
}
}
</script>
<svelte:head>
@ -113,6 +133,16 @@
<p class="rationale">{b.rationale}</p>
{/if}
</div>
<button
type="button"
class="revert-btn"
title="Alle Writes dieser Iteration zurücknehmen"
disabled={revertingKey !== null}
onclick={() => handleRevert(b.key, b.missionId, b.iterationId)}
>
<ArrowCounterClockwise size={14} />
<span>{revertingKey === b.key ? 'Läuft…' : 'Revert'}</span>
</button>
</header>
<ul class="events">
{#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;