mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 10:59:39 +02:00
feat(ai): production wiring for Mission Runner + default input resolvers
Connects the dependency-injected Runner to the real LlmOrchestrator and
drives it on a foreground tick in the app shell. Registers sensible
default input resolvers so Missions linked to notes / kontext / goals
work without per-module opt-in.
- `data/ai/missions/setup.ts`
- `productionDeps` wires `aiPlanTask` through `llmOrchestrator.run`
- `startMissionTick(intervalMs = 60_000)` kicks an immediate run then
schedules `runDueMissions` on interval. Idempotent + overlap-guarded
so a slow LLM run can't pile up ticks.
- `stopMissionTick` clears the interval for teardown / HMR.
- `data/ai/missions/default-resolvers.ts` — resolvers for notes (title +
decrypted content), kontext (singleton markdown), goals (progress
projection). Registered once when the tick starts.
- `(app)/+layout.svelte` wires startMissionTick into the idle-phase init
block alongside startEventStore / startStreakTracker / etc., and
stopMissionTick into the teardown path.
System is now end-to-end runnable in the browser: create a Mission with
cadence 'interval', wait for the tick, see proposals appear in
/todo's AiProposalInbox. Missions UI (create/edit form) still open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c6201be50
commit
7535480007
4 changed files with 228 additions and 0 deletions
|
|
@ -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<NoteLike>(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<KontextDocLike>('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<GoalLike>(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;
|
||||
}
|
||||
58
apps/mana/apps/web/src/lib/data/ai/missions/setup.test.ts
Normal file
58
apps/mana/apps/web/src/lib/data/ai/missions/setup.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
79
apps/mana/apps/web/src/lib/data/ai/missions/setup.ts
Normal file
79
apps/mana/apps/web/src/lib/data/ai/missions/setup.ts
Normal file
|
|
@ -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<AiPlanOutput> => {
|
||||
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<typeof setInterval> | 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue