refactor(webapp): delete proposal infrastructure + ai-plan legacy wrappers

Runner no longer creates proposals (commit 5a) and no module renders
the inbox (commit 5b), so the supporting code is dead. This commit
deletes it.

Removed:
- data/ai/proposals/ (types, store, queries + tests) — the entire
  Proposal model + createProposal/listProposals/approveProposal API.
- components/ai/AiProposalInbox.svelte — orphaned after commit 5b.
- data/ai/missions/server-iteration-staging.ts + its test — the bridge
  that turned server-produced iterations into local proposals. Server
  iterations will land with executed steps directly once commit 6
  migrates the server runner.
- data/ai/missions/planner/ — all webapp re-exports of the old
  buildPlannerPrompt / parsePlannerResponse / AiPlanInput types. The
  new runner imports its types directly from @mana/shared-ai.
- llm-tasks/ai-plan.ts — the old LlmTask that wrapped the text-JSON
  request/parse cycle for the LlmOrchestrator. Replaced by the direct
  mana-llm client in missions/llm-client.ts.

Updated:
- data/database.ts — v29 drops the `pendingProposals` table (passing
  null to .stores() deletes it on next open). Safe because nothing is
  live.
- routes/(app)/+layout.svelte — no more startServerIterationStaging /
  stopServerIterationStaging in the bootstrap/teardown pair.
- data/ai/missions/types.ts — strips the planStepStatusFromProposal
  bridge helper (proposals don't exist any more).
- data/ai/missions/input-resolvers.ts — imports ResolvedInput from
  @mana/shared-ai directly.
- data/tools/executor.test.ts — the proposal-staging test block is
  rewritten to match the new semantics: auto and propose both execute
  inline, only deny refuses.

Net: ~1100 LoC removed, 0 added. Type-check green, 15 tests pass
across executor + runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:30:13 +02:00
parent 08b7ac16bf
commit 2d15684ed4
19 changed files with 26 additions and 1555 deletions

View file

@ -1,325 +0,0 @@
<!--
AiProposalInbox — renders pending AI proposals inline inside a module.
Drop this component anywhere in a module page. It shows zero UI when no
proposals are pending (so the module layout is unaffected when the AI is
idle) and a list of approval cards when the AI has staged intents.
Each card displays:
- the rationale ("why" from the AI)
- a human-readable preview of the intent (tool name + params)
- Approve / Reject buttons that run `approveProposal` / `rejectProposal`
and close themselves via Dexie's live query
The cards use a "ghost" style (semi-transparent, dashed border) so they
read as "not real yet" — inspired by Figma's multiplayer cursors and
Google Docs suggestions.
-->
<script lang="ts">
import { Check, X, Sparkle } from '@mana/shared-icons';
import { useAiProposals } from '$lib/data/ai/proposals/queries';
import { approveProposal, rejectProposal } from '$lib/data/ai/proposals/store';
import { getTool } from '$lib/data/tools/registry';
import type { Proposal } from '$lib/data/ai/proposals/types';
interface Props {
/** Filter proposals to tools belonging to this module (e.g. 'todo').
* Omit when filtering by mission only — the inbox will then render
* every pending proposal across modules and add a module badge to
* each card so the user knows where it'll land on approve. */
module?: string;
/** Filter to proposals from a specific mission. Combine with `module`
* to scope to that mission's proposals for a single module. */
missionId?: string;
}
let { module, missionId }: Props = $props();
const proposals = $derived(useAiProposals({ status: 'pending', module, missionId }));
/** Show module badge whenever the inbox is cross-module (i.e. the
* caller didn't pin it to a single module). */
const showModuleBadge = $derived(!module);
let busyId = $state<string | null>(null);
/** Proposal whose reject-feedback textarea is currently open. */
let rejectingId = $state<string | null>(null);
let rejectDraft = $state('');
async function handleApprove(p: Proposal) {
busyId = p.id;
try {
await approveProposal(p.id);
} catch (err) {
console.error('[AiProposalInbox] approve failed:', err);
} finally {
busyId = null;
}
}
function openRejectForm(p: Proposal) {
rejectingId = p.id;
rejectDraft = '';
}
function cancelReject() {
rejectingId = null;
rejectDraft = '';
}
async function confirmReject(p: Proposal) {
busyId = p.id;
try {
// Trimmed feedback, or undefined when empty — downstream planner
// sees the field as absent rather than as an empty string.
const feedback = rejectDraft.trim().length > 0 ? rejectDraft.trim() : undefined;
await rejectProposal(p.id, feedback);
rejectingId = null;
rejectDraft = '';
} catch (err) {
console.error('[AiProposalInbox] reject failed:', err);
} finally {
busyId = null;
}
}
function formatIntent(p: Proposal): string {
if (p.intent.kind !== 'toolCall') return JSON.stringify(p.intent);
const tool = getTool(p.intent.toolName);
const label = tool?.description ?? p.intent.toolName;
const paramsPreview = Object.entries(p.intent.params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' · ');
return paramsPreview ? `${label} — ${paramsPreview}` : label;
}
</script>
{#if proposals.value.length > 0}
<section class="inbox" aria-label="Vorschläge der KI">
{#each proposals.value as p (p.id)}
<article class="card" class:busy={busyId === p.id}>
<header class="header">
{#if p.actor?.kind === 'ai'}
<span class="agent-chip" title={`Mission: ${p.actor.missionId.slice(0, 8)}…`}>
<span class="agent-avatar-dot">🤖</span>
<span class="agent-name">{p.actor.displayName}</span>
</span>
<span class="label">schlägt vor</span>
{:else}
<Sparkle size={16} weight="fill" />
<span class="label">KI schlägt vor</span>
{/if}
{#if showModuleBadge && p.intent.kind === 'toolCall'}
{@const mod = getTool(p.intent.toolName)?.module ?? '?'}
<span class="module-badge">{mod}</span>
{/if}
</header>
<p class="intent">{formatIntent(p)}</p>
{#if p.rationale}
<p class="rationale">{p.rationale}</p>
{/if}
{#if rejectingId === p.id}
<form class="reject-form" onsubmit={(e) => (e.preventDefault(), confirmReject(p))}>
<label class="reject-label" for={`reject-${p.id}`}>
Warum ablehnen? (optional — hilft der KI beim nächsten Versuch)
</label>
<textarea
id={`reject-${p.id}`}
bind:value={rejectDraft}
rows="2"
placeholder="z.B. zu aggressiv, oder: falsches Datum gewählt"
></textarea>
<div class="reject-actions">
<button type="button" class="btn" disabled={busyId !== null} onclick={cancelReject}>
Abbrechen
</button>
<button type="submit" class="btn reject-confirm" disabled={busyId !== null}>
Ablehnen
</button>
</div>
</form>
{:else}
<footer class="actions">
<button
type="button"
class="btn reject"
disabled={busyId !== null}
onclick={() => openRejectForm(p)}
aria-label="Ablehnen"
>
<X size={16} weight="bold" />
<span>Ablehnen</span>
</button>
<button
type="button"
class="btn approve"
disabled={busyId !== null}
onclick={() => handleApprove(p)}
aria-label="Übernehmen"
>
<Check size={16} weight="bold" />
<span>Übernehmen</span>
</button>
</footer>
{/if}
</article>
{/each}
</section>
{/if}
<style>
.inbox {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.card {
position: relative;
padding: 0.75rem 1rem;
border: 1px dashed color-mix(in oklab, var(--color-primary, #6b5bff) 55%, transparent);
border-radius: 0.625rem;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 6%, var(--color-bg, transparent));
transition: opacity 150ms ease;
}
.card.busy {
opacity: 0.55;
pointer-events: none;
}
.header {
display: flex;
align-items: center;
gap: 0.375rem;
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.module-badge {
margin-left: auto;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 18%, transparent);
color: color-mix(in oklab, var(--color-primary, #6b5bff) 90%, var(--color-fg, #000));
font-size: 0.6875rem;
letter-spacing: 0.02em;
text-transform: lowercase;
}
.agent-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, transparent);
color: color-mix(in oklab, var(--color-primary, #6b5bff) 95%, var(--color-fg, #000));
font-size: 0.75rem;
text-transform: none;
letter-spacing: 0;
}
.agent-avatar-dot {
font-size: 0.875rem;
line-height: 1;
}
.agent-name {
font-weight: 600;
}
.intent {
margin: 0.375rem 0 0;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--color-fg, inherit);
}
.rationale {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--color-muted, #666);
font-style: italic;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.625rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
background: var(--color-bg, #fff);
color: var(--color-fg, inherit);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.btn:hover:not(:disabled) {
border-color: color-mix(in oklab, var(--color-fg, #000) 30%, transparent);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.btn.approve {
background: color-mix(in oklab, var(--color-primary, #6b5bff) 12%, var(--color-bg, #fff));
border-color: color-mix(in oklab, var(--color-primary, #6b5bff) 45%, transparent);
color: color-mix(in oklab, var(--color-primary, #6b5bff) 85%, var(--color-fg, #000));
}
.btn.approve:hover:not(:disabled) {
background: color-mix(in oklab, var(--color-primary, #6b5bff) 20%, var(--color-bg, #fff));
}
.reject-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.625rem;
}
.reject-label {
font-size: 0.75rem;
color: var(--color-muted, #666);
}
.reject-form textarea {
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 0.375rem;
font: inherit;
resize: vertical;
background: var(--color-bg, #fff);
color: var(--color-fg, inherit);
}
.reject-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn.reject-confirm {
background: #fff0f0;
border-color: #e99;
color: #8a1b1b;
}
.btn.reject-confirm:hover:not(:disabled) {
background: #ffe4e4;
}
</style>

View file

@ -15,7 +15,7 @@
*/
import type { MissionInputRef } from './types';
import type { ResolvedInput } from './planner/types';
import type { ResolvedInput } from '@mana/shared-ai';
export type InputResolver = (ref: MissionInputRef) => Promise<ResolvedInput | null>;

View file

@ -1,92 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parsePlannerResponse } from './parser';
const TOOLS = new Set(['create_task', 'log_drink']);
describe('parsePlannerResponse', () => {
it('parses a valid fenced json block', () => {
const text = `\`\`\`json
{
"summary": "Plan für heute",
"steps": [
{ "summary": "Task anlegen", "toolName": "create_task", "params": { "title": "Foo" }, "rationale": "weil wichtig" }
]
}
\`\`\``;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.summary).toBe('Plan für heute');
expect(r.value.steps).toHaveLength(1);
expect(r.value.steps[0].toolName).toBe('create_task');
expect(r.value.steps[0].params).toEqual({ title: 'Foo' });
});
it('accepts a bare JSON object without fence', () => {
const text = `{ "summary": "x", "steps": [
{ "summary": "log", "toolName": "log_drink", "params": {}, "rationale": "Routine" }
]}`;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(true);
});
it('rejects when no JSON block found', () => {
const r = parsePlannerResponse('just prose no JSON here', TOOLS);
expect(r.ok).toBe(false);
});
it('rejects invalid JSON inside the fence', () => {
const r = parsePlannerResponse('```json\n{not: valid}\n```', TOOLS);
expect(r.ok).toBe(false);
});
it('rejects when steps is missing or not an array', () => {
const r = parsePlannerResponse('```json\n{"summary":"x"}\n```', TOOLS);
expect(r.ok).toBe(false);
});
it('rejects steps referencing unknown tools', () => {
const text = `\`\`\`json
{ "summary": "", "steps": [{ "toolName": "nuke_database", "params": {}, "rationale": "why not" }] }
\`\`\``;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toContain('nuke_database');
});
it('rejects steps missing rationale', () => {
const text = `\`\`\`json
{ "summary": "", "steps": [{ "toolName": "create_task", "params": { "title": "x" } }] }
\`\`\``;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toContain('rationale');
});
it('tolerates missing summary / step summary by defaulting to empty', () => {
const text = `\`\`\`json
{
"steps": [
{ "toolName": "create_task", "params": {}, "rationale": "need one" }
]
}
\`\`\``;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.summary).toBe('');
expect(r.value.steps[0].summary).toBe('');
});
it('accepts an empty steps array (no-op iteration)', () => {
const text = `\`\`\`json
{ "summary": "nothing to do today", "steps": [] }
\`\`\``;
const r = parsePlannerResponse(text, TOOLS);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.steps).toHaveLength(0);
});
});

View file

@ -1,7 +0,0 @@
/**
* Re-export of the Planner response parser from @mana/shared-ai.
* Kept here so existing webapp imports (and tests) keep working.
*/
export { parsePlannerResponse } from '@mana/shared-ai';
export type { ParseResult } from '@mana/shared-ai';

View file

@ -1,127 +0,0 @@
import { describe, it, expect } from 'vitest';
import { buildPlannerPrompt } from './prompt';
import type { AiPlanInput } from './types';
import type { Mission } from '../types';
function baseMission(overrides: Partial<Mission> = {}): Mission {
return {
id: 'm-1',
createdAt: '2026-04-14T10:00:00.000Z',
updatedAt: '2026-04-14T10:00:00.000Z',
title: 'Weekly review',
conceptMarkdown: '# Concept\nDo a thing.',
objective: 'Review progress each Monday',
inputs: [],
cadence: { kind: 'weekly', dayOfWeek: 1, atHour: 9 },
state: 'active',
iterations: [],
...overrides,
};
}
describe('buildPlannerPrompt', () => {
it('emits system + user messages with mission title and objective', () => {
const input: AiPlanInput = {
mission: baseMission(),
resolvedInputs: [],
availableTools: [],
};
const { system, user } = buildPlannerPrompt(input);
expect(user).toContain('Weekly review');
expect(user).toContain('Review progress each Monday');
expect(system).toContain('JSON');
expect(system).toContain('rationale');
});
it('lists available tools with their params in the system prompt', () => {
const input: AiPlanInput = {
mission: baseMission(),
resolvedInputs: [],
availableTools: [
{
name: 'create_task',
module: 'todo',
description: 'Creates a task',
parameters: [
{ name: 'title', type: 'string', required: true, description: 'Task title' },
{
name: 'priority',
type: 'string',
required: false,
description: 'prio',
enum: ['low', 'high'],
},
],
},
],
};
const { system } = buildPlannerPrompt(input);
expect(system).toContain('create_task');
expect(system).toContain('title');
expect(system).toContain('(required)');
expect(system).toContain('[low|high]');
});
it('injects resolved input content into the user prompt', () => {
const input: AiPlanInput = {
mission: baseMission({
inputs: [{ module: 'notes', table: 'notes', id: 'n-1' }],
}),
resolvedInputs: [
{ id: 'n-1', module: 'notes', table: 'notes', title: 'Strategy', content: 'Be bold.' },
],
availableTools: [],
};
const { user } = buildPlannerPrompt(input);
expect(user).toContain('Strategy');
expect(user).toContain('Be bold.');
});
it('includes user feedback from the most recent iteration', () => {
const input: AiPlanInput = {
mission: baseMission({
iterations: [
{
id: 'it-1',
startedAt: '2026-04-07T09:00:00.000Z',
finishedAt: '2026-04-07T09:01:00.000Z',
plan: [
{
id: 's-1',
summary: 'Old step',
intent: { kind: 'toolCall', toolName: 'create_task', params: {} },
status: 'rejected',
},
],
userFeedback: 'Zu aggressiv — bitte zurücknehmen',
overallStatus: 'rejected',
},
],
}),
resolvedInputs: [],
availableTools: [],
};
const { user } = buildPlannerPrompt(input);
expect(user).toContain('Zu aggressiv');
expect(user).toContain('[rejected]');
});
it('truncates iteration history to the last 3', () => {
const many = Array.from({ length: 10 }, (_, i) => ({
id: `it-${i}`,
startedAt: `2026-04-${String(i + 1).padStart(2, '0')}T10:00:00.000Z`,
plan: [],
overallStatus: 'approved' as const,
userFeedback: `feedback-${i}`,
}));
const { user } = buildPlannerPrompt({
mission: baseMission({ iterations: many }),
resolvedInputs: [],
availableTools: [],
});
// Only the last three iterations (7, 8, 9) should be present
expect(user).toContain('feedback-9');
expect(user).toContain('feedback-7');
expect(user).not.toContain('feedback-5');
});
});

View file

@ -1,7 +0,0 @@
/**
* Re-export of the Planner prompt builder from @mana/shared-ai.
* Kept here so existing webapp imports (and tests) keep working.
*/
export { buildPlannerPrompt } from '@mana/shared-ai';
export type { PlannerMessages } from '@mana/shared-ai';

View file

@ -1,12 +0,0 @@
/**
* Re-export of Planner types from @mana/shared-ai. The shared package is
* the source of truth webapp and mana-ai service both import from it.
*/
export type {
AiPlanInput,
AiPlanOutput,
AvailableTool,
PlannedStep,
ResolvedInput,
} from '@mana/shared-ai';

View file

@ -1,171 +0,0 @@
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi, afterEach } 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 { registerTools } from '../../tools/registry';
import { setAiPolicy } from '../policy';
import { createMission, finishIteration, startIteration } from './store';
import { MISSIONS_TABLE } from './types';
import { listProposals } from '../proposals/store';
import { PROPOSALS_TABLE } from '../proposals/types';
import {
startServerIterationStaging,
stopServerIterationStaging,
resetServerIterationStagingCache,
} from './server-iteration-staging';
registerTools([
{
name: 'staging_test_op',
module: 'stagingTest',
description: 'propose only',
parameters: [{ name: 'val', type: 'string', required: true, description: 'v' }],
async execute() {
return { success: true, message: 'ok' };
},
},
]);
const flush = () => new Promise((r) => setTimeout(r, 50));
beforeEach(async () => {
await db.table(MISSIONS_TABLE).clear();
await db.table(PROPOSALS_TABLE).clear();
resetServerIterationStagingCache();
});
afterEach(() => {
stopServerIterationStaging();
});
describe('server-iteration staging', () => {
it('translates a server iteration into local proposals', async () => {
const restore = setAiPolicy({
tools: { staging_test_op: 'propose' },
defaultForAi: 'propose',
});
try {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
// Simulate what mana-ai's write-back would sync into Dexie
const it = await startIteration(m.id, {
plan: [
{
id: 'srv-step-1',
summary: 'server step',
intent: {
kind: 'toolCall',
toolName: 'staging_test_op',
params: { val: 'hello' },
},
status: 'planned',
},
],
});
await finishIteration(m.id, it.id, {
overallStatus: 'awaiting-review',
});
// Stamp source='server' — startIteration/finishIteration don't
// set it; the write-back path from mana-ai does.
const row = await db.table(MISSIONS_TABLE).get(m.id);
const patched = row.iterations.map((x: { id: string }) =>
x.id === it.id ? { ...x, source: 'server' } : x
);
await db.table(MISSIONS_TABLE).update(m.id, { iterations: patched });
startServerIterationStaging();
await flush();
await flush();
const proposals = await listProposals({ status: 'pending' });
expect(proposals).toHaveLength(1);
expect(proposals[0].missionId).toBe(m.id);
expect(proposals[0].iterationId).toBe(it.id);
expect(proposals[0].intent).toMatchObject({
kind: 'toolCall',
toolName: 'staging_test_op',
params: { val: 'hello' },
});
} finally {
restore();
}
});
it('does not re-stage an iteration that already has proposalIds', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
const it = await startIteration(m.id, {
plan: [
{
id: 'srv-step-1',
summary: 's',
intent: {
kind: 'toolCall',
toolName: 'staging_test_op',
params: { val: 'x' },
},
status: 'staged',
proposalId: 'already-there',
},
],
});
await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' });
const row = await db.table(MISSIONS_TABLE).get(m.id);
const patched = row.iterations.map((x: { id: string }) =>
x.id === it.id ? { ...x, source: 'server' } : x
);
await db.table(MISSIONS_TABLE).update(m.id, { iterations: patched });
startServerIterationStaging();
await flush();
await flush();
const proposals = await listProposals({ status: 'pending' });
expect(proposals).toHaveLength(0);
});
it('ignores browser-sourced iterations', async () => {
const m = await createMission({
title: 'x',
conceptMarkdown: '',
objective: 'x',
cadence: { kind: 'manual' },
});
const it = await startIteration(m.id, {
plan: [
{
id: 'browser-step',
summary: 's',
intent: {
kind: 'toolCall',
toolName: 'staging_test_op',
params: { val: 'x' },
},
status: 'planned',
},
],
});
await finishIteration(m.id, it.id, { overallStatus: 'awaiting-review' });
// leave source unset (defaults to 'browser')
startServerIterationStaging();
await flush();
const proposals = await listProposals({ status: 'pending' });
expect(proposals).toHaveLength(0);
});
});

View file

@ -1,148 +0,0 @@
/**
* Server-iteration staging translates server-produced Mission
* iterations into local Proposals.
*
* The mana-ai Bun service writes plans back as a new `Mission.iterations[]`
* entry with `source: 'server'`. When the webapp syncs, `applyServerChanges`
* merges the new iterations array into the local record. This module
* subscribes to those updates and, for each server iteration we haven't
* processed yet, creates a Proposal per PlanStep via the existing
* `createProposal` flow.
*
* Idempotency: each iteration id is tracked in a local Set so re-runs
* (e.g. after tab reopen) don't duplicate proposals. Proposals that
* successfully create get their id written back into `plan[i].proposalId`
* so the Workbench UI links them; that also doubles as a durable
* "already staged" marker surviving app restarts.
*/
import { liveQuery } from 'dexie';
import { db } from '../../database';
import { MISSIONS_TABLE } from './types';
import { createProposal } from '../proposals/store';
import { getMission } from './store';
import { runAsAsync, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor';
import { getAgent } from '../agents/store';
import { DEFAULT_AGENT_NAME } from '../agents/types';
import type { Mission, MissionIteration, PlanStep } from './types';
const processedIterations = new Set<string>();
let subscription: { unsubscribe: () => void } | null = null;
/**
* Start subscribing to aiMissions changes. Each time a server iteration
* without staged proposals shows up, translate every PlanStep into a
* local Proposal under the originating mission's AI actor.
*
* Idempotent calling twice is a no-op. Returns a stop function.
*/
export function startServerIterationStaging(): () => void {
if (subscription) return stopServerIterationStaging;
const obs = liveQuery(() => db.table<Mission>(MISSIONS_TABLE).toArray());
subscription = obs.subscribe({
next: async (missions) => {
for (const m of missions) {
if (m.deletedAt) continue;
for (const it of m.iterations) {
if (it.source !== 'server') continue;
if (processedIterations.has(it.id)) continue;
// Pre-check: if any plan step already has a proposalId, the
// server iteration was already staged (possibly by another
// tab). Mark as processed so we don't race.
const alreadyStaged = it.plan.some(
(s) => typeof s.proposalId === 'string' && s.proposalId.length > 0
);
if (alreadyStaged) {
processedIterations.add(it.id);
continue;
}
try {
await stageIteration(m, it);
processedIterations.add(it.id);
} catch (err) {
console.error(
`[server-staging] mission=${m.id} iteration=${it.id} failed:`,
err instanceof Error ? err.message : String(err)
);
}
}
}
},
error: (err) => {
console.error('[server-staging] subscription error:', err);
},
});
return stopServerIterationStaging;
}
export function stopServerIterationStaging(): void {
subscription?.unsubscribe();
subscription = null;
}
/** Test hook — forget which iterations we've already staged. */
export function resetServerIterationStagingCache(): void {
processedIterations.clear();
}
async function stageIteration(mission: Mission, iteration: MissionIteration): Promise<void> {
// Re-read the freshest mission so concurrent local edits don't get
// clobbered when we write proposalIds back into `plan[]`.
const fresh = await getMission(mission.id);
if (!fresh) return;
const stagedStepIds: Record<string, string> = {};
// Resolve the owning agent once per iteration (not per step) — agent
// identity doesn't change mid-iteration. Legacy missions or missions
// whose agent was deleted fall back to the legacy principal.
const owningAgent = fresh.agentId ? await getAgent(fresh.agentId) : null;
const actorAgentId = owningAgent?.id ?? LEGACY_AI_PRINCIPAL;
const actorDisplayName = owningAgent?.name ?? DEFAULT_AGENT_NAME;
for (const step of iteration.plan) {
const intent = step.intent;
if (intent.kind !== 'toolCall') continue;
if (step.proposalId) continue; // already staged
const actor = makeAgentActor({
agentId: actorAgentId,
displayName: actorDisplayName,
missionId: mission.id,
iterationId: iteration.id,
rationale: step.summary || iteration.summary || mission.objective,
});
// createProposal runs through Dexie hooks under the AI actor — the
// row lands in `pendingProposals` and the AiProposalInbox renders
// it as a ghost card on the relevant module page.
const proposal = await runAsAsync(actor, () =>
createProposal({
actor,
intent: {
kind: 'toolCall',
toolName: intent.toolName,
params: intent.params,
},
rationale: actor.rationale,
})
);
stagedStepIds[step.id] = proposal.id;
}
if (Object.keys(stagedStepIds).length === 0) return;
// Write proposalIds back onto the iteration's plan[] so the Workbench
// UI links each step to its proposal AND so other tabs skip re-staging.
const updatedIterations: MissionIteration[] = fresh.iterations.map((it) => {
if (it.id !== iteration.id) return it;
const updatedPlan: PlanStep[] = it.plan.map((s) =>
stagedStepIds[s.id] ? { ...s, proposalId: stagedStepIds[s.id], status: 'staged' as const } : s
);
return { ...it, plan: updatedPlan };
});
await db.table(MISSIONS_TABLE).update(fresh.id, {
iterations: updatedIterations,
updatedAt: new Date().toISOString(),
});
}

View file

@ -1,14 +1,11 @@
/**
* Webapp-local re-export of Mission types from @mana/shared-ai plus
* storage-layer concerns (Dexie table name, proposal-status bridge).
* storage-layer concerns (Dexie table name).
*
* The runtime types themselves live in the shared package so the
* mana-ai Bun service parses identical rows.
*/
import type { ProposalStatus } from '../proposals/types';
import type { PlanStep } from '@mana/shared-ai';
export type {
Mission,
MissionCadence,
@ -21,17 +18,3 @@ export type {
} from '@mana/shared-ai';
export const MISSIONS_TABLE = 'aiMissions';
/** Helper — derive a summary status for a proposal-id lookup. */
export function planStepStatusFromProposal(status: ProposalStatus): PlanStep['status'] {
switch (status) {
case 'pending':
return 'staged';
case 'approved':
return 'approved';
case 'rejected':
return 'rejected';
case 'expired':
return 'skipped';
}
}

View file

@ -1,92 +0,0 @@
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 { registerTools } from '../../tools/registry';
import { createProposal } from './store';
import { PROPOSALS_TABLE } from './types';
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
// Register two tools in distinct modules so the `module` filter has
// something to discriminate against.
registerTools([
{
name: 'queries_test_todo_op',
module: 'todo',
description: 'd',
parameters: [],
async execute() {
return { success: true, message: 'ok' };
},
},
{
name: 'queries_test_calendar_op',
module: 'calendar',
description: 'd',
parameters: [],
async execute() {
return { success: true, message: 'ok' };
},
},
]);
const AI: AiActor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-a',
iterationId: 'i-a',
rationale: 'r',
});
beforeEach(async () => {
await db.table(PROPOSALS_TABLE).clear();
});
describe('proposal filters (logic used by useAiProposals)', () => {
it('filters by module via the tool registry lookup', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_calendar_op', params: {} },
rationale: 'r',
});
// Replicate the filter logic used inside the live query
const { getTool } = await import('../../tools/registry');
const all = await db.table(PROPOSALS_TABLE).toArray();
const todoOnly = all.filter((p) => {
if (p.intent.kind !== 'toolCall') return false;
const tool = getTool(p.intent.toolName);
return tool?.module === 'todo';
});
expect(todoOnly).toHaveLength(1);
expect(todoOnly[0].intent.toolName).toBe('queries_test_todo_op');
});
it('filters by missionId', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
await createProposal({
actor: { ...AI, missionId: 'm-b' },
intent: { kind: 'toolCall', toolName: 'queries_test_todo_op', params: {} },
rationale: 'r',
});
const all = await db.table(PROPOSALS_TABLE).toArray();
const onlyA = all.filter((p) => p.missionId === 'm-a');
expect(onlyA).toHaveLength(1);
});
});

View file

@ -1,42 +0,0 @@
/**
* Reactive queries over the `pendingProposals` Dexie table.
*
* Used by the AI workbench UI and by per-module proposal inboxes so each
* module can render the AI's staged intents inline.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '../../database';
import { getTool } from '../../tools/registry';
import type { Proposal, ProposalStatus } from './types';
import { PROPOSALS_TABLE } from './types';
export interface UseProposalsOptions {
/** Filter by lifecycle state. Defaults to `'pending'`. */
status?: ProposalStatus;
/** Filter to proposals whose intent targets tools in this module. */
module?: string;
/** Filter to a specific mission. */
missionId?: string;
}
/**
* Svelte 5 live query returning proposals matching the given filter.
* Re-runs whenever `pendingProposals` changes.
*/
export function useAiProposals(options: UseProposalsOptions = {}) {
const { status = 'pending', module, missionId } = options;
return useLiveQueryWithDefault(async () => {
const all = await db.table<Proposal>(PROPOSALS_TABLE).orderBy('createdAt').reverse().toArray();
return all.filter((p) => {
if (p.status !== status) return false;
if (missionId && p.missionId !== missionId) return false;
if (module) {
if (p.intent.kind !== 'toolCall') return false;
const tool = getTool(p.intent.toolName);
if (!tool || tool.module !== module) return false;
}
return true;
});
}, [] as Proposal[]);
}

View file

@ -1,139 +0,0 @@
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 { registerTools } from '../../tools/registry';
import {
createProposal,
listProposals,
approveProposal,
rejectProposal,
expireOldProposals,
getProposal,
} from './store';
import { PROPOSALS_TABLE } from './types';
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
const AI: AiActor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'mission-1',
iterationId: 'iter-1',
rationale: 'test run',
});
let executed: { name: string; params: Record<string, unknown> }[] = [];
registerTools([
{
name: 'proposal_test_echo',
module: 'proposalTest',
description: 'Records invocation for assertions',
parameters: [{ name: 'value', type: 'string', description: 'v', required: true }],
async execute(params) {
executed.push({ name: 'proposal_test_echo', params: { ...params } });
return { success: true, message: `echo ${params.value}` };
},
},
]);
beforeEach(async () => {
executed = [];
await db.table(PROPOSALS_TABLE).clear();
});
describe('proposal lifecycle', () => {
it('creates a pending proposal', async () => {
const p = await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'a' } },
rationale: 'because',
});
expect(p.status).toBe('pending');
expect(p.missionId).toBe('mission-1');
expect(p.rationale).toBe('because');
expect(await getProposal(p.id)).toBeTruthy();
});
it('lists pending proposals by filter', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'a' } },
rationale: 'r',
});
await createProposal({
actor: { ...AI, missionId: 'mission-2' },
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'b' } },
rationale: 'r',
});
const all = await listProposals({ status: 'pending' });
expect(all).toHaveLength(2);
const m2 = await listProposals({ missionId: 'mission-2' });
expect(m2).toHaveLength(1);
});
it('approving runs the intent and marks the proposal approved', async () => {
const p = await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'go' } },
rationale: 'r',
});
const { proposal, result } = await approveProposal(p.id, 'looks good');
expect(result.success).toBe(true);
expect(executed).toEqual([{ name: 'proposal_test_echo', params: { value: 'go' } }]);
expect(proposal.status).toBe('approved');
expect(proposal.userFeedback).toBe('looks good');
const persisted = await getProposal(p.id);
expect(persisted?.status).toBe('approved');
expect(persisted?.decidedBy).toBe('user');
});
it('rejecting stores feedback and does not execute the intent', async () => {
const p = await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
rationale: 'r',
});
await rejectProposal(p.id, 'not now');
const persisted = await getProposal(p.id);
expect(persisted?.status).toBe('rejected');
expect(persisted?.userFeedback).toBe('not now');
expect(executed).toHaveLength(0);
});
it('refuses to approve a non-pending proposal', async () => {
const p = await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
rationale: 'r',
});
await rejectProposal(p.id);
await expect(approveProposal(p.id)).rejects.toThrow(/rejected/);
});
it('expires proposals past their expiresAt', async () => {
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'x' } },
rationale: 'r',
expiresAt: '2020-01-01T00:00:00.000Z',
});
await createProposal({
actor: AI,
intent: { kind: 'toolCall', toolName: 'proposal_test_echo', params: { value: 'y' } },
rationale: 'r',
expiresAt: '2099-01-01T00:00:00.000Z',
});
const count = await expireOldProposals(new Date('2026-04-14T00:00:00.000Z'));
expect(count).toBe(1);
const pending = await listProposals({ status: 'pending' });
expect(pending).toHaveLength(1);
});
});

View file

@ -1,168 +0,0 @@
/**
* Proposal store create, list, approve, reject, expire.
*
* Approval re-runs the tool the AI originally called, but this time forces
* the executor into `auto` mode so the stored intent can't bounce back into
* another proposal. Rejection just marks the row the AI sees the feedback
* on the next planner pass via `listProposals({ status: 'rejected' })`.
*/
import { db } from '../../database';
import { runAsAsync } from '../../events/actor';
import type { Actor } from '../../events/actor';
import type { ToolResult } from '../../tools/types';
import type { Intent, Proposal, ProposalStatus } from './types';
import { PROPOSALS_TABLE } from './types';
const table = () => db.table<Proposal>(PROPOSALS_TABLE);
export interface CreateProposalInput {
actor: Extract<Actor, { kind: 'ai' }>;
intent: Intent;
rationale: string;
/** ISO timestamp. Falsy → no auto-expiry. */
expiresAt?: string;
}
export async function createProposal(input: CreateProposalInput): Promise<Proposal> {
const proposal: Proposal = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
expiresAt: input.expiresAt,
status: 'pending',
actor: input.actor,
missionId: input.actor.missionId,
iterationId: input.actor.iterationId,
rationale: input.rationale,
intent: input.intent,
};
await table().add(proposal);
return proposal;
}
export async function getProposal(id: string): Promise<Proposal | undefined> {
return table().get(id);
}
export interface ListProposalsFilter {
status?: ProposalStatus;
missionId?: string;
}
export async function listProposals(filter: ListProposalsFilter = {}): Promise<Proposal[]> {
let coll = table().orderBy('createdAt').reverse();
if (filter.status) coll = coll.filter((p) => p.status === filter.status);
if (filter.missionId) coll = coll.filter((p) => p.missionId === filter.missionId);
return coll.toArray();
}
/**
* Approve a pending proposal. Runs the stored intent with the AI actor
* re-installed so downstream events and records carry the original
* mission/iteration attribution critical for the Workbench timeline.
*
* The executor is forced into `auto` by construction: the approved
* `executeTool` call re-reads policy, which would again say `propose`
* so we bypass policy by calling the tool implementation directly under
* the `ai` actor instead of routing through the policy-gated executor.
*/
export async function approveProposal(
id: string,
userFeedback?: string
): Promise<{ proposal: Proposal; result: ToolResult }> {
const proposal = await getProposal(id);
if (!proposal) throw new Error(`Proposal not found: ${id}`);
if (proposal.status !== 'pending') {
throw new Error(`Proposal ${id} is ${proposal.status}, cannot approve`);
}
const result = await runApprovedIntent(proposal);
const updated: Partial<Proposal> = {
status: 'approved',
decidedAt: new Date().toISOString(),
decidedBy: 'user',
userFeedback,
};
await table().update(id, updated);
return { proposal: { ...proposal, ...updated }, result };
}
export async function rejectProposal(id: string, userFeedback?: string): Promise<Proposal> {
const proposal = await getProposal(id);
if (!proposal) throw new Error(`Proposal not found: ${id}`);
if (proposal.status !== 'pending') {
throw new Error(`Proposal ${id} is ${proposal.status}, cannot reject`);
}
const updated: Partial<Proposal> = {
status: 'rejected',
decidedAt: new Date().toISOString(),
decidedBy: 'user',
userFeedback,
};
await table().update(id, updated);
// Bubble the feedback up to the Mission iteration so the next Planner
// pass (which reads `mission.iterations[].userFeedback` — NOT
// `pendingProposals.userFeedback`) can course-correct. Lazy-import
// to avoid a cycle: mission store ↔ proposal store.
if (userFeedback && proposal.missionId && proposal.iterationId) {
try {
const { addIterationFeedback, getMission } = await import('../missions/store');
const mission = await getMission(proposal.missionId);
// Merge with any existing feedback on the iteration — different
// steps within one iteration can produce different reasons.
const existingIt = mission?.iterations.find((it) => it.id === proposal.iterationId);
const merged = existingIt?.userFeedback
? `${existingIt.userFeedback}\${userFeedback}`
: userFeedback;
await addIterationFeedback(proposal.missionId, proposal.iterationId, merged);
} catch (err) {
// Feedback bubble is best-effort — the proposal was still
// rejected successfully if this fails.
console.error('[rejectProposal] failed to bubble feedback to iteration:', err);
}
}
return { ...proposal, ...updated };
}
/**
* Mark any pending proposal whose `expiresAt` has passed as expired. Fire
* this from a low-frequency tick (e.g. on app focus); cheap, indexed scan.
*/
export async function expireOldProposals(now: Date = new Date()): Promise<number> {
const cutoff = now.toISOString();
const stale = await table()
.where('status')
.equals('pending')
.filter((p) => typeof p.expiresAt === 'string' && p.expiresAt < cutoff)
.toArray();
for (const p of stale) {
await table().update(p.id, {
status: 'expired',
decidedAt: cutoff,
decidedBy: 'auto-expire',
});
}
return stale.length;
}
/**
* Run the intent under the original AI actor, bypassing policy. The user
* has consented via approval; re-entering the policy gate would bounce the
* call straight back into a new proposal.
*
* The `executor` import is lazy: `tools/executor.ts` imports this file's
* `createProposal`, so a top-level import here would form a cycle.
*/
async function runApprovedIntent(proposal: Proposal): Promise<ToolResult> {
return runAsAsync(proposal.actor, async () => {
if (proposal.intent.kind === 'toolCall') {
const { executeToolRaw } = await import('../../tools/executor');
return executeToolRaw(proposal.intent.toolName, proposal.intent.params);
}
throw new Error(`Unsupported intent kind: ${(proposal.intent as { kind: string }).kind}`);
});
}

View file

@ -1,62 +0,0 @@
/**
* Proposals staged AI intents awaiting user approval.
*
* When an AI-attributed tool call hits a policy of `propose`, the executor
* records a {@link Proposal} instead of performing the underlying mutation.
* The proposal sits in the local `pendingProposals` Dexie table until the
* user approves it ( run the intent), rejects it, or it auto-expires.
*
* Proposals are intentionally local-only they do not sync through
* mana-sync. The approved mutation syncs normally once executed, so
* other devices see the resulting write without ever seeing the proposal
* state machine.
*/
import type { Actor } from '../../events/actor';
/** Lifecycle states a proposal can be in. */
export type ProposalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
/**
* Structured description of what the AI wants to happen if the proposal is
* approved. Start with `toolCall` (execute the named tool with params) and
* extend the union with `patch` / `create` variants once module UIs need
* to render field-level diffs inline.
*/
export type Intent = ToolCallIntent;
export interface ToolCallIntent {
readonly kind: 'toolCall';
readonly toolName: string;
readonly params: Record<string, unknown>;
}
export interface Proposal {
readonly id: string;
readonly createdAt: string;
readonly expiresAt?: string;
readonly status: ProposalStatus;
/**
* The AI actor that submitted this proposal. Always `kind: 'ai'` by
* construction `resolvePolicy` never routes user/system writes here.
*/
readonly actor: Actor;
/** Mirrors `actor.missionId` for index-based queries of "all proposals in mission X". */
readonly missionId?: string;
/** Mirrors `actor.iterationId`. */
readonly iterationId?: string;
/** The AI's stated reason for the change — surfaced in the approval UI. */
readonly rationale: string;
/** What runs on approve. */
readonly intent: Intent;
/** Set when the proposal leaves the `pending` state. */
readonly decidedAt?: string;
readonly decidedBy?: 'user' | 'auto-expire';
/** Free-text feedback from the user, captured on approve or reject. */
readonly userFeedback?: string;
}
export const PROPOSALS_TABLE = 'pendingProposals';

View file

@ -651,6 +651,16 @@ db.version(28).upgrade(async (tx) => {
}
});
// v29 — Drop the legacy `pendingProposals` table. Proposals are no
// longer created: the planner executes tool_calls directly under the
// AI actor, and the Workbench Timeline plus per-iteration revert is
// the review surface. Passing `null` to .stores() deletes the table on
// open. Safe because the system hasn't shipped; no user data is lost.
// See docs/plans/planner-function-calling.md.
db.version(29).stores({
pendingProposals: null,
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -10,9 +10,6 @@ vi.mock('$lib/triggers/inline-suggest', () => ({
import { executeTool } from './executor';
import { registerTools, getTools } from './registry';
import { setAiPolicy } from '../ai/policy';
import { listProposals, approveProposal } from '../ai/proposals/store';
import { PROPOSALS_TABLE } from '../ai/proposals/types';
import { db } from '../database';
import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../events/actor';
import type { ModuleTool } from './types';
@ -125,45 +122,24 @@ describe('Tool Executor', () => {
});
describe('Tool Executor — AI policy routing', () => {
beforeEach(async () => {
await db.table(PROPOSALS_TABLE).clear();
});
it('runs a tool directly for user actors regardless of name', async () => {
// test_echo has no policy entry — user default is always auto
it('runs a tool directly for user actors', async () => {
const result = await executeTool('test_echo', { text: 'hi' });
expect(result.success).toBe(true);
expect(result.message).toBe('echo: hi');
});
it('stages a proposal when ai actor hits a propose-policy tool', async () => {
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
try {
const result = await executeTool('test_echo', { text: 'stage-me' }, AI);
expect(result.success).toBe(true);
expect(result.message).toMatch(/Vorgeschlagen/);
expect((result.data as { proposalId: string }).proposalId).toBeTruthy();
// Tool did NOT run — it was staged
const pending = await listProposals({ status: 'pending' });
expect(pending).toHaveLength(1);
expect(pending[0].rationale).toBe('because');
expect(pending[0].missionId).toBe('m-1');
} finally {
restore();
}
});
it('runs directly for ai actor when policy says auto', async () => {
const restore = setAiPolicy({ tools: { test_echo: 'auto' }, defaultForAi: 'propose' });
try {
const result = await executeTool('test_echo', { text: 'direct' }, AI);
expect(result.success).toBe(true);
expect(result.message).toBe('echo: direct');
const pending = await listProposals({ status: 'pending' });
expect(pending).toHaveLength(0);
} finally {
restore();
it('executes directly for AI actors when policy allows (auto or propose)', async () => {
// Post-migration: both auto and propose execute inline — the
// proposal gate is gone. Only 'deny' refuses.
for (const policy of ['auto', 'propose'] as const) {
const restore = setAiPolicy({ tools: { test_echo: policy }, defaultForAi: 'propose' });
try {
const result = await executeTool('test_echo', { text: `via-${policy}` }, AI);
expect(result.success).toBe(true);
expect(result.message).toBe(`echo: via-${policy}`);
} finally {
restore();
}
}
});
@ -178,29 +154,12 @@ describe('Tool Executor — AI policy routing', () => {
}
});
it('approval runs the staged intent with original actor attribution', async () => {
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
try {
const staged = await executeTool('test_echo', { text: 'approved' }, AI);
const proposalId = (staged.data as { proposalId: string }).proposalId;
const { result, proposal } = await approveProposal(proposalId);
expect(result.success).toBe(true);
expect(result.message).toBe('echo: approved');
expect(proposal.status).toBe('approved');
} finally {
restore();
}
});
it('still validates parameters before staging a proposal', async () => {
it('validates parameters before executing regardless of actor', async () => {
const restore = setAiPolicy({ tools: { test_echo: 'propose' }, defaultForAi: 'propose' });
try {
const result = await executeTool('test_echo', {}, AI);
expect(result.success).toBe(false);
expect(result.message).toContain('Missing required parameter');
const pending = await listProposals({ status: 'pending' });
expect(pending).toHaveLength(0);
} finally {
restore();
}

View file

@ -1,79 +0,0 @@
/**
* aiPlanTask LLM task for the Mission Planner.
*
* Takes a Mission plus pre-resolved inputs + available tools, asks the
* configured LLM backend for a structured plan, parses it, and returns
* typed steps the Runner turns into Proposals.
*
* Routing:
* - `minTier: 'browser'` the Planner runs entirely on the device by
* default. Users can override to mana-server / cloud in settings for
* more capable reasoning on long missions.
* - `contentClass: 'personal'` the prompt contains the user's notes and
* goals. If any linked input is from a strictly-sensitive module
* (journal, dreams, finance), the Runner is responsible for narrowing
* to `'sensitive'` on the request so cloud is refused.
*
* Error path: the parser returns a structured `ParseResult`. If parsing
* fails, the task still returns with `steps: []` and a summary
* explaining why so the Runner can record a failed iteration without
* throwing through the whole mission queue.
*/
import type { LlmBackend, LlmTask } from '@mana/shared-llm';
import { buildPlannerPrompt } from '$lib/data/ai/missions/planner/prompt';
import { parsePlannerResponse } from '$lib/data/ai/missions/planner/parser';
import type { AiPlanInput, AiPlanOutput } from '$lib/data/ai/missions/planner/types';
export type { AiPlanInput, AiPlanOutput } from '$lib/data/ai/missions/planner/types';
export const aiPlanTask: LlmTask<AiPlanInput, AiPlanOutput> = {
name: 'ai.plan',
minTier: 'browser',
contentClass: 'personal',
requires: { streaming: false },
displayLabel: 'AI Mission Planner',
async runLlm(input: AiPlanInput, backend: LlmBackend): Promise<AiPlanOutput> {
const { system, user } = buildPlannerPrompt(input);
const result = await backend.generate({
taskName: 'ai.plan',
contentClass: 'personal',
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature: 0.3,
// 1024 truncates mid-response when the planner proposes 3+ steps with
// rich rationales — the reasoning loop amplifies this because a
// single round can legitimately stage one step per listed item
// (e.g. 10 notes → 10 add_tag_to_note calls). 4096 fits ~15-20
// step objects while still fast on browser tier.
maxTokens: 4096,
onToken: input.onToken,
});
// Always populate debug payload (cheap — strings already in memory).
// The runner decides whether to persist it based on the user's
// localStorage `mana.ai.debug` toggle.
const debug = {
systemPrompt: system,
userPrompt: user,
rawResponse: result.content,
latencyMs: result.latencyMs,
};
const knownToolNames = new Set(input.availableTools.map((t) => t.name));
const parsed = parsePlannerResponse(result.content, knownToolNames);
if (!parsed.ok) {
return {
steps: [],
summary: `Plan konnte nicht erzeugt werden: ${parsed.reason}`,
debug,
};
}
return { ...parsed.value, debug };
},
};

View file

@ -8,10 +8,6 @@
import { todoReminderSource } from '$lib/modules/todo/reminder-source';
import { startEventStore, stopEventStore } from '$lib/data/events/event-store';
import { startMissionTick, stopMissionTick } from '$lib/data/ai/missions/setup';
import {
startServerIterationStaging,
stopServerIterationStaging,
} from '$lib/data/ai/missions/server-iteration-staging';
import { initTools } from '$lib/data/tools/init';
import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge';
import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks';
@ -539,11 +535,6 @@
// interval and runs any that are due. Safe idempotent; see
// data/ai/missions/setup.ts.
startMissionTick();
// Staging-effect: subscribes to Mission updates and translates
// server-produced iterations (source='server') into local
// Proposals. Essential once the mana-ai service is running
// alongside; no-op when only the foreground tick is active.
startServerIterationStaging();
});
// Restore nav collapsed state (cheap, keep inline)
@ -639,7 +630,6 @@
stopStreakTracker();
stopGoalTracker();
stopMissionTick();
stopServerIterationStaging();
guestMode?.destroy();
// Fire-and-forget — we don't need to await; the in-flight task
// will finish in the background and the next page session will