mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 02:39:41 +02:00
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:
parent
99efb93816
commit
ad5f670ec2
4 changed files with 346 additions and 2 deletions
|
|
@ -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 };
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue