feat(mana-ai): server-side input resolvers (goals for now)

Plugs plaintext-safe Mission context into the Planner prompt per tick.
Before this, `resolvedInputs: []` was always passed — the LLM only saw
the mission's concept + objective. Now goals (the only plaintext
category of linked inputs today) resolve and land in the prompt.

Privacy constraint is explicit and documented: tables in the webapp's
encryption registry (notes, kontext, journal, dreams, …) arrive at
`sync_changes.data` as ciphertext — the master key lives in mana-auth
KEK-wrapped and never reaches this service. Resolvers for encrypted
modules therefore don't exist server-side; missions referencing them
should use the foreground runner which decrypts client-side.

- `db/resolvers/types.ts` — ServerInputResolver contract
- `db/resolvers/record-replay.ts` — single-record LWW replay
  (tighter WHERE than `missions-projection.ts`, used by all resolvers)
- `db/resolvers/goals.ts` — reads `companionGoals` via replayRecord,
  mirrors the webapp's default goalsResolver output shape
- `db/resolvers/index.ts` — registry with `registerServerResolver` /
  `unregisterServerResolver` / `resolveServerInputs`. Seeds `goals`.
  Drift-tolerant: missions pointing at unregistered modules silently
  skip those inputs.
- `cron/tick.ts` — wires `resolveServerInputs(sql, m.inputs, m.userId)`
  into the planner input; updates the outdated "stubbed" comment

5 Bun tests over the registry (handled + unhandled + thrown +
mixed cases + seeded default).

Future: expand to plaintext tables if/when more land (habits without
free-text, dashboard configs, tags), or introduce a decrypt-via-auth
sidecar if users opt into server-side access to encrypted content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 00:42:45 +02:00
parent 39b24b2c68
commit a8425941fb
6 changed files with 281 additions and 10 deletions

View file

@ -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<TickStats> {
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<TickStats> {
*/
async function planOneMission(
m: ServerMission,
planner: PlannerClient
planner: PlannerClient,
sql: Sql
): Promise<AiPlanOutput | null> {
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);

View file

@ -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})`,
};
};

View file

@ -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<string, ServerInputResolver>();
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<ResolvedInput[]> {
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';

View file

@ -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<string, unknown> | null;
field_timestamps: Record<string, string> | null;
created_at: Date;
}
export async function replayRecord(
sql: Sql,
userId: string,
appId: string,
tableName: string,
recordId: string
): Promise<Record<string, unknown> | null> {
return withUser(sql, userId, async (tx) => {
const rows = await tx<ChangeRow[]>`
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<string, unknown> | null = null;
let fieldTimestamps: Record<string, string> = {};
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;
});
}

View file

@ -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([]);
});
});

View file

@ -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<ResolvedInput | null>;