mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
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:
parent
39b24b2c68
commit
a8425941fb
6 changed files with 281 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
42
services/mana-ai/src/db/resolvers/goals.ts
Normal file
42
services/mana-ai/src/db/resolvers/goals.ts
Normal 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})`,
|
||||
};
|
||||
};
|
||||
55
services/mana-ai/src/db/resolvers/index.ts
Normal file
55
services/mana-ai/src/db/resolvers/index.ts
Normal 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';
|
||||
73
services/mana-ai/src/db/resolvers/record-replay.ts
Normal file
73
services/mana-ai/src/db/resolvers/record-replay.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
69
services/mana-ai/src/db/resolvers/resolvers.test.ts
Normal file
69
services/mana-ai/src/db/resolvers/resolvers.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
28
services/mana-ai/src/db/resolvers/types.ts
Normal file
28
services/mana-ai/src/db/resolvers/types.ts
Normal 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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue