From 2d15684ed4020ad3d2010717441c460161b8372e Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 16:30:13 +0200 Subject: [PATCH] refactor(webapp): delete proposal infrastructure + ai-plan legacy wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/components/ai/AiProposalInbox.svelte | 325 ------------------ .../lib/data/ai/missions/input-resolvers.ts | 2 +- .../data/ai/missions/planner/parser.test.ts | 92 ----- .../lib/data/ai/missions/planner/parser.ts | 7 - .../data/ai/missions/planner/prompt.test.ts | 127 ------- .../lib/data/ai/missions/planner/prompt.ts | 7 - .../src/lib/data/ai/missions/planner/types.ts | 12 - .../missions/server-iteration-staging.test.ts | 171 --------- .../ai/missions/server-iteration-staging.ts | 148 -------- .../web/src/lib/data/ai/missions/types.ts | 19 +- .../src/lib/data/ai/proposals/queries.test.ts | 92 ----- .../web/src/lib/data/ai/proposals/queries.ts | 42 --- .../src/lib/data/ai/proposals/store.test.ts | 139 -------- .../web/src/lib/data/ai/proposals/store.ts | 168 --------- .../web/src/lib/data/ai/proposals/types.ts | 62 ---- apps/mana/apps/web/src/lib/data/database.ts | 10 + .../web/src/lib/data/tools/executor.test.ts | 69 +--- .../apps/web/src/lib/llm-tasks/ai-plan.ts | 79 ----- .../apps/web/src/routes/(app)/+layout.svelte | 10 - 19 files changed, 26 insertions(+), 1555 deletions(-) delete mode 100644 apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.test.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.test.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/planner/types.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.test.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/store.ts delete mode 100644 apps/mana/apps/web/src/lib/data/ai/proposals/types.ts delete mode 100644 apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts diff --git a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte b/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte deleted file mode 100644 index 5355cf0d8..000000000 --- a/apps/mana/apps/web/src/lib/components/ai/AiProposalInbox.svelte +++ /dev/null @@ -1,325 +0,0 @@ - - - -{#if proposals.value.length > 0} -
- {#each proposals.value as p (p.id)} -
-
- {#if p.actor?.kind === 'ai'} - - 🤖 - {p.actor.displayName} - - schlägt vor - {:else} - - KI schlägt vor - {/if} - {#if showModuleBadge && p.intent.kind === 'toolCall'} - {@const mod = getTool(p.intent.toolName)?.module ?? '?'} - {mod} - {/if} -
- -

{formatIntent(p)}

- - {#if p.rationale} -

{p.rationale}

- {/if} - - {#if rejectingId === p.id} -
(e.preventDefault(), confirmReject(p))}> - - -
- - -
-
- {:else} -
- - -
- {/if} -
- {/each} -
-{/if} - - diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/input-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/input-resolvers.ts index aaf4779bf..40cd55915 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/input-resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/input-resolvers.ts @@ -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; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.test.ts deleted file mode 100644 index f36b8c4ad..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.ts b/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.ts deleted file mode 100644 index edf8f019e..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/planner/parser.ts +++ /dev/null @@ -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'; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.test.ts deleted file mode 100644 index e2ca62749..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.test.ts +++ /dev/null @@ -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 { - 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'); - }); -}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.ts b/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.ts deleted file mode 100644 index 56911bcc0..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/planner/prompt.ts +++ /dev/null @@ -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'; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/planner/types.ts b/apps/mana/apps/web/src/lib/data/ai/missions/planner/types.ts deleted file mode 100644 index 353ac813e..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/planner/types.ts +++ /dev/null @@ -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'; diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.test.ts deleted file mode 100644 index d2933d295..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts deleted file mode 100644 index 50d5f4e00..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts +++ /dev/null @@ -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(); -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(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 { - // 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 = {}; - - // 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(), - }); -} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/types.ts b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts index ebdc89218..68d92e35f 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/types.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts @@ -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'; - } -} diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts deleted file mode 100644 index 715bed886..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts deleted file mode 100644 index d49ef5e22..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.ts +++ /dev/null @@ -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(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[]); -} diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts deleted file mode 100644 index ef77f1b5e..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts +++ /dev/null @@ -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 }[] = []; - -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); - }); -}); diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/store.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/store.ts deleted file mode 100644 index bcfdf132a..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/store.ts +++ /dev/null @@ -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(PROPOSALS_TABLE); - -export interface CreateProposalInput { - actor: Extract; - intent: Intent; - rationale: string; - /** ISO timestamp. Falsy → no auto-expiry. */ - expiresAt?: string; -} - -export async function createProposal(input: CreateProposalInput): Promise { - 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 { - return table().get(id); -} - -export interface ListProposalsFilter { - status?: ProposalStatus; - missionId?: string; -} - -export async function listProposals(filter: ListProposalsFilter = {}): Promise { - 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 = { - 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 { - 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 = { - 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}\n· ${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 { - 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 { - 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}`); - }); -} diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/types.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/types.ts deleted file mode 100644 index a9942dce5..000000000 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/types.ts +++ /dev/null @@ -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; -} - -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'; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 5210dc575..d5d86c9e2 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/tools/executor.test.ts b/apps/mana/apps/web/src/lib/data/tools/executor.test.ts index 6cbb1f637..8d9583d8b 100644 --- a/apps/mana/apps/web/src/lib/data/tools/executor.test.ts +++ b/apps/mana/apps/web/src/lib/data/tools/executor.test.ts @@ -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(); } diff --git a/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts b/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts deleted file mode 100644 index edfb7b135..000000000 --- a/apps/mana/apps/web/src/lib/llm-tasks/ai-plan.ts +++ /dev/null @@ -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 = { - name: 'ai.plan', - minTier: 'browser', - contentClass: 'personal', - requires: { streaming: false }, - displayLabel: 'AI Mission Planner', - - async runLlm(input: AiPlanInput, backend: LlmBackend): Promise { - 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 }; - }, -}; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 058502c5d..d81c53d3e 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -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