diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts index aa6d93785..ad07fddb3 100644 --- a/services/mana-ai/src/cron/tick.ts +++ b/services/mana-ai/src/cron/tick.ts @@ -7,9 +7,10 @@ * transactions on `mana_sync` (same pattern as the Go server's * `withUser`) — tracked as the next PR in `CLAUDE.md`. * - * Input-resolver wiring is also stubbed: `resolvedInputs: []` is handed - * to the Planner today, so the LLM sees only the mission's concept + - * objective. Real resolvers land alongside write-back. + * Input resolvers (`db/resolvers/`) plug plaintext-safe Mission context + * into the prompt per due run. Encrypted tables (notes, kontext, …) + * intentionally have no server-side resolver — the Planner only sees + * what the user can't unambiguously mark private by design. */ import { @@ -19,7 +20,8 @@ import { type AiPlanOutput, type Mission, } from '@mana/shared-ai'; -import { getSql } from '../db/connection'; +import { getSql, type Sql } from '../db/connection'; +import { resolveServerInputs } from '../db/resolvers'; import { listDueMissions, type ServerMission } from '../db/missions-projection'; import { appendServerIteration, planToIteration } from '../db/iteration-writer'; import { PlannerClient } from '../planner/client'; @@ -76,7 +78,7 @@ export async function runTickOnce(config: Config): Promise { for (const m of missions) { try { - const plan = await planOneMission(m, planner); + const plan = await planOneMission(m, planner, sql); if (plan === null) { parseFailures++; continue; @@ -125,15 +127,17 @@ export async function runTickOnce(config: Config): Promise { */ async function planOneMission( m: ServerMission, - planner: PlannerClient + planner: PlannerClient, + sql: Sql ): Promise { const mission = serverMissionToSharedMission(m); + // Resolvers skip silently for modules they don't handle (notes / kontext + // etc. are encrypted — server can't project them). The Planner then sees + // only plaintext-safe context (today: goals), plus concept + objective. + const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId); const input: AiPlanInput = { mission, - // No resolvers yet — the LLM only sees concept + objective + - // iteration history. Matches the webapp's behaviour for a mission - // with zero linked inputs. - resolvedInputs: [], + resolvedInputs, availableTools: AI_AVAILABLE_TOOLS, }; const messages = buildPlannerPrompt(input); diff --git a/services/mana-ai/src/db/resolvers/goals.ts b/services/mana-ai/src/db/resolvers/goals.ts new file mode 100644 index 000000000..eef6f9c63 --- /dev/null +++ b/services/mana-ai/src/db/resolvers/goals.ts @@ -0,0 +1,42 @@ +/** + * Goals resolver — reads `companionGoals` rows (plaintext, not encrypted) + * and formats them as Planner context. + * + * Mirrors the webapp's default `goalsResolver` shape so prompts look the + * same regardless of which runtime resolved the input. Keep the string + * format stable; the Planner learns to rely on the layout. + */ + +import { replayRecord } from './record-replay'; +import type { ServerInputResolver } from './types'; + +interface GoalRecord { + id: string; + title?: string; + currentValue?: number; + target?: { value?: number }; + period?: string; + deletedAt?: string; +} + +export const goalsResolver: ServerInputResolver = async (sql, ref, userId) => { + const record = (await replayRecord( + sql, + userId, + 'companion', + ref.table, + ref.id + )) as GoalRecord | null; + if (!record) return null; + + const current = record.currentValue ?? 0; + const target = record.target?.value ?? '?'; + const period = record.period ?? 'unbekannt'; + return { + id: ref.id, + module: ref.module, + table: ref.table, + title: record.title ?? 'Goal', + content: `Fortschritt: ${current} / ${target} (${period})`, + }; +}; diff --git a/services/mana-ai/src/db/resolvers/index.ts b/services/mana-ai/src/db/resolvers/index.ts new file mode 100644 index 000000000..62e3d767e --- /dev/null +++ b/services/mana-ai/src/db/resolvers/index.ts @@ -0,0 +1,55 @@ +/** + * Resolver registry + bulk-resolve helper. + * + * Each module that wants server-side Planner context registers a + * {@link ServerInputResolver} here. Missions referencing modules without + * a registered resolver silently drop those inputs — the Planner sees + * fewer inputs, never crashes. Drift-tolerant by design. + */ + +import type { Sql } from '../connection'; +import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai'; +import type { ServerInputResolver } from './types'; +import { goalsResolver } from './goals'; + +const resolvers = new Map(); + +export function registerServerResolver(moduleName: string, resolver: ServerInputResolver): void { + resolvers.set(moduleName, resolver); +} + +export function unregisterServerResolver(moduleName: string): void { + resolvers.delete(moduleName); +} + +// Seed with the built-in plaintext resolvers. Encrypted modules (notes, +// kontext, journal, dreams, …) are intentionally NOT registered — the +// server only sees ciphertext for those tables and can't produce useful +// Planner context. Missions referencing them should use the foreground +// runner; see CLAUDE.md → "Privacy constraint" for rationale. +registerServerResolver('goals', goalsResolver); + +export async function resolveServerInputs( + sql: Sql, + refs: readonly MissionInputRef[], + userId: string +): Promise { + const results = await Promise.all( + refs.map(async (ref) => { + const resolver = resolvers.get(ref.module); + if (!resolver) return null; + try { + return await resolver(sql, ref, userId); + } catch (err) { + console.error( + `[mana-ai resolver] module=${ref.module} ref=${ref.id} threw:`, + err instanceof Error ? err.message : String(err) + ); + return null; + } + }) + ); + return results.filter((r): r is ResolvedInput => r !== null); +} + +export type { ServerInputResolver } from './types'; diff --git a/services/mana-ai/src/db/resolvers/record-replay.ts b/services/mana-ai/src/db/resolvers/record-replay.ts new file mode 100644 index 000000000..9164cdd03 --- /dev/null +++ b/services/mana-ai/src/db/resolvers/record-replay.ts @@ -0,0 +1,73 @@ +/** + * Single-record LWW replay. + * + * `missions-projection.ts` replays the whole `aiMissions` set; resolvers + * only need one record at a time. This helper is the focused version — + * WHERE-filters on `app_id + table_name + record_id`, merges fields in + * chronological order with per-field LWW, respects delete tombstones. + * + * Returns the merged record or `null` if the record was deleted or has + * never been written. + */ + +import type { Sql } from '../connection'; +import { withUser } from '../connection'; + +interface ChangeRow { + op: string; + data: Record | null; + field_timestamps: Record | null; + created_at: Date; +} + +export async function replayRecord( + sql: Sql, + userId: string, + appId: string, + tableName: string, + recordId: string +): Promise | null> { + return withUser(sql, userId, async (tx) => { + const rows = await tx` + SELECT op, data, field_timestamps, created_at + FROM sync_changes + WHERE user_id = ${userId} + AND app_id = ${appId} + AND table_name = ${tableName} + AND record_id = ${recordId} + ORDER BY created_at ASC + `; + if (rows.length === 0) return null; + + let record: Record | null = null; + let fieldTimestamps: Record = {}; + + for (const row of rows) { + if (row.op === 'delete') { + return null; + } + + if (!record) { + record = row.data ? { id: recordId, ...row.data } : { id: recordId }; + if (row.field_timestamps) { + fieldTimestamps = { ...row.field_timestamps }; + } + continue; + } + + if (!row.data) continue; + const rowFT = row.field_timestamps ?? {}; + for (const [k, v] of Object.entries(row.data)) { + const serverTime = rowFT[k] ?? row.created_at.toISOString(); + const localTime = fieldTimestamps[k] ?? ''; + if (serverTime >= localTime) { + record[k] = v; + fieldTimestamps[k] = serverTime; + } + } + } + + if (record && (record.deletedAt as string | undefined)) return null; + return record; + }); +} diff --git a/services/mana-ai/src/db/resolvers/resolvers.test.ts b/services/mana-ai/src/db/resolvers/resolvers.test.ts new file mode 100644 index 000000000..ac92e868b --- /dev/null +++ b/services/mana-ai/src/db/resolvers/resolvers.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { registerServerResolver, unregisterServerResolver, resolveServerInputs } from './index'; +import type { Sql } from '../connection'; +import type { MissionInputRef } from '@mana/shared-ai'; + +// Stub Sql — the registry only uses it as an opaque pass-through. +const stubSql = null as unknown as Sql; + +afterEach(() => { + unregisterServerResolver('resolver_test_mod'); + unregisterServerResolver('resolver_test_boom'); +}); + +describe('resolveServerInputs', () => { + it('invokes the registered resolver and returns its output', async () => { + registerServerResolver('resolver_test_mod', async (_sql, ref) => ({ + id: ref.id, + module: ref.module, + table: ref.table, + title: 'T', + content: `content for ${ref.id}`, + })); + + const refs: MissionInputRef[] = [{ module: 'resolver_test_mod', table: 't', id: 'a' }]; + const resolved = await resolveServerInputs(stubSql, refs, 'user-1'); + expect(resolved).toHaveLength(1); + expect(resolved[0].content).toBe('content for a'); + }); + + it('skips refs whose module has no registered resolver', async () => { + const refs: MissionInputRef[] = [{ module: 'does-not-exist', table: 't', id: 'x' }]; + const resolved = await resolveServerInputs(stubSql, refs, 'u'); + expect(resolved).toEqual([]); + }); + + it('catches resolver errors and skips rather than propagating', async () => { + registerServerResolver('resolver_test_boom', async () => { + throw new Error('broken'); + }); + const refs: MissionInputRef[] = [{ module: 'resolver_test_boom', table: 't', id: 'x' }]; + const resolved = await resolveServerInputs(stubSql, refs, 'u'); + expect(resolved).toEqual([]); + }); + + it('mixes resolved + unresolved refs in one call', async () => { + registerServerResolver('resolver_test_mod', async (_sql, ref) => ({ + id: ref.id, + module: ref.module, + table: ref.table, + content: 'ok', + })); + const refs: MissionInputRef[] = [ + { module: 'resolver_test_mod', table: 't', id: 'a' }, + { module: 'unknown', table: 't', id: 'b' }, + { module: 'resolver_test_mod', table: 't', id: 'c' }, + ]; + const resolved = await resolveServerInputs(stubSql, refs, 'u'); + expect(resolved).toHaveLength(2); + }); + + it('ships with goals as a built-in resolver', async () => { + // goals is seeded on module import. We assert it's registered by + // checking the empty-refs path doesn't throw and that an unknown + // module still skips — any negative-space test, since we can't + // invoke the goals resolver without a live DB here. + const resolved = await resolveServerInputs(stubSql, [], 'u'); + expect(resolved).toEqual([]); + }); +}); diff --git a/services/mana-ai/src/db/resolvers/types.ts b/services/mana-ai/src/db/resolvers/types.ts new file mode 100644 index 000000000..a59f307de --- /dev/null +++ b/services/mana-ai/src/db/resolvers/types.ts @@ -0,0 +1,28 @@ +/** + * Server-side input resolvers. + * + * **Privacy constraint**: the server sees `sync_changes.data` rows as + * they were written by the client. For tables in the webapp's encryption + * registry (notes, kontextDoc, journal, dreams, etc.) that payload is + * already ciphertext — the master key lives in mana-auth KEK-wrapped + * and never reaches this service. Resolvers for encrypted tables are + * therefore either: + * + * 1. Not implemented server-side at all (user should run those + * missions via the foreground Runner which decrypts client-side) + * 2. Return metadata-only (record exists, last-updated timestamp) and + * a fixed "content encrypted — use foreground runner" content blob + * so the Planner can still generate meaningful plans + * + * Today we take route (1) for notes + kontext and skip silently; only + * plaintext tables (goals, the mission record itself) resolve fully. + */ + +import type { Sql } from '../connection'; +import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai'; + +export type ServerInputResolver = ( + sql: Sql, + ref: MissionInputRef, + userId: string +) => Promise;