diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts new file mode 100644 index 000000000..70f786573 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts @@ -0,0 +1,85 @@ +/** + * Default input resolvers. + * + * Registered from `setup.ts` so the production MissionRunner can load + * notes / kontext / goals without every module having to know about the + * AI subsystem. Modules that need special projection logic register their + * own resolver on init and override these defaults. + */ + +import { db } from '../../database'; +import { decryptRecords } from '../../crypto'; +import { registerInputResolver } from './input-resolvers'; +import type { InputResolver } from './input-resolvers'; + +interface NoteLike { + id: string; + title?: string; + content?: string; + deletedAt?: string; +} + +const notesResolver: InputResolver = async (ref) => { + const local = await db.table(ref.table).get(ref.id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords(ref.table, [local]); + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: decrypted.title, + content: decrypted.content ?? '', + }; +}; + +interface KontextDocLike { + id: string; + content?: string; +} + +const kontextResolver: InputResolver = async (ref) => { + const doc = await db.table('kontextDoc').get(ref.id); + if (!doc) return null; + const [decrypted] = await decryptRecords('kontextDoc', [doc]); + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: 'Kontext', + content: decrypted.content ?? '', + }; +}; + +interface GoalLike { + id: string; + title?: string; + currentValue?: number; + target?: { value: number }; + period?: string; + deletedAt?: string; +} + +const goalsResolver: InputResolver = async (ref) => { + const goal = await db.table(ref.table).get(ref.id); + if (!goal || goal.deletedAt) return null; + const current = goal.currentValue ?? 0; + const target = goal.target?.value ?? '?'; + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: goal.title ?? 'Goal', + content: `Fortschritt: ${current} / ${target} (${goal.period ?? 'unbekannt'})`, + }; +}; + +let registered = false; + +/** Register the default resolvers once. Idempotent. */ +export function registerDefaultInputResolvers(): void { + if (registered) return; + registerInputResolver('notes', notesResolver); + registerInputResolver('kontext', kontextResolver); + registerInputResolver('goals', goalsResolver); + registered = true; +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/setup.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/setup.test.ts new file mode 100644 index 000000000..86d392bc6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/setup.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +// Mock the heavy deps so this is a pure scheduler test — we don't exercise +// the orchestrator or Dexie from here; those are covered by runner.test.ts. +vi.mock('@mana/shared-llm', () => ({ + llmOrchestrator: { + run: vi.fn().mockResolvedValue({ value: { summary: '', steps: [] } }), + }, +})); +vi.mock('./runner', () => ({ + runDueMissions: vi.fn().mockResolvedValue([]), +})); +vi.mock('./default-resolvers', () => ({ + registerDefaultInputResolvers: vi.fn(), +})); + +import { startMissionTick, stopMissionTick, isMissionTickRunning } from './setup'; +import { runDueMissions } from './runner'; +import { registerDefaultInputResolvers } from './default-resolvers'; + +afterEach(() => { + stopMissionTick(); + vi.clearAllMocks(); +}); + +describe('mission tick scheduler', () => { + it('starts, runs once immediately, and schedules an interval', async () => { + vi.useFakeTimers(); + try { + startMissionTick(5_000); + expect(isMissionTickRunning()).toBe(true); + expect(registerDefaultInputResolvers).toHaveBeenCalledOnce(); + + // Wait for the immediate run to complete + await vi.advanceTimersByTimeAsync(1); + expect(runDueMissions).toHaveBeenCalledTimes(1); + + // Advance past one interval — should tick again + await vi.advanceTimersByTimeAsync(5_000); + expect(runDueMissions).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('is idempotent — double-start does not schedule two intervals', () => { + startMissionTick(10_000); + startMissionTick(10_000); + expect(isMissionTickRunning()).toBe(true); + }); + + it('stop clears the interval', () => { + startMissionTick(10_000); + expect(isMissionTickRunning()).toBe(true); + stopMissionTick(); + expect(isMissionTickRunning()).toBe(false); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts new file mode 100644 index 000000000..ce55c65bb --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/setup.ts @@ -0,0 +1,79 @@ +/** + * Production wiring for the Mission Runner. + * + * Connects the dependency-injected `runMission` to the real LlmOrchestrator + * (via `aiPlanTask`) and drives `runDueMissions` on a foreground interval. + * + * Use pattern: + * + * // in +layout.svelte (app-shell init) + * import { startMissionTick } from '$lib/data/ai/missions/setup'; + * onMount(() => startMissionTick()); + * + * The tick is intentionally foreground-only: the Runner requires the + * LlmOrchestrator which needs WebGPU / network. A background service for + * offline-of-tab execution is tracked as Phase 7 — see + * COMPANION_BRAIN_ARCHITECTURE.md §20.5. + */ + +import { llmOrchestrator } from '@mana/shared-llm'; +import { aiPlanTask } from '$lib/llm-tasks/ai-plan'; +import { runDueMissions, type MissionRunnerDeps } from './runner'; +import { registerDefaultInputResolvers } from './default-resolvers'; +import type { AiPlanInput, AiPlanOutput } from './planner/types'; + +/** Default interval between tick scans. One minute is fine for foreground use. */ +const DEFAULT_TICK_INTERVAL_MS = 60_000; + +/** Swap-in planner that routes through the real LLM orchestrator. */ +const productionPlan = async (input: AiPlanInput): Promise => { + const result = await llmOrchestrator.run(aiPlanTask, input); + return result.value; +}; + +export const productionDeps: MissionRunnerDeps = { + plan: productionPlan, + // stageStep defaults to the policy-gated executor — nothing to override here. +}; + +let tickHandle: ReturnType | null = null; +let ticking = false; + +/** + * Start the Mission tick. Idempotent — calling twice is a no-op. Returns a + * stop function so test / teardown code can cancel it. + */ +export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS): () => void { + if (tickHandle !== null) return stopMissionTick; + registerDefaultInputResolvers(); + + const tickOnce = async () => { + // Guard against overlap — a slow LLM run could pile up multiple ticks. + if (ticking) return; + ticking = true; + try { + await runDueMissions(new Date(), productionDeps); + } catch (err) { + console.error('[MissionTick] scan threw:', err); + } finally { + ticking = false; + } + }; + + // Run once immediately so a just-due mission doesn't wait a full interval. + void tickOnce(); + tickHandle = setInterval(() => void tickOnce(), intervalMs); + return stopMissionTick; +} + +export function stopMissionTick(): void { + if (tickHandle !== null) { + clearInterval(tickHandle); + tickHandle = null; + } +} + +/** Test / debug helper — whether the tick is currently scheduled. */ +export function isMissionTickRunning(): boolean { + return tickHandle !== null; +} diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 75ea0efc0..ae2eab66f 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -6,6 +6,7 @@ import { createReminderScheduler } from '@mana/shared-stores'; 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 { initTools } from '$lib/data/tools/init'; import { startEventBridge, stopEventBridge } from '$lib/triggers/event-bridge'; import { startStreakTracker, stopStreakTracker } from '$lib/data/projections/streaks'; @@ -509,6 +510,10 @@ // routes don't read from it on first paint. void dashboardStore.initialize(); reminderScheduler.start(); + // AI Mission tick — scans pendingProposals/aiMissions on an + // interval and runs any that are due. Safe idempotent; see + // data/ai/missions/setup.ts. + startMissionTick(); }); // Restore nav collapsed state (cheap, keep inline) @@ -604,6 +609,7 @@ stopEventStore(); stopEventBridge(); stopStreakTracker(); + stopMissionTick(); 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