feat(mana-ai): scaffold server-side Mission Runner (v0.1)

Background Hono/Bun service that scans mana_sync for due Missions and
will plan them via mana-llm without requiring an open browser tab.
Complements the foreground `startMissionTick` in the webapp.

v0.1 scope — scaffold that's deployable, boots cleanly, and reads real
data. Execution write-back is tracked as the next PR so we don't commit
a half-baked proposal-sync design.

Shipped:
- Hono app on :3066 with `/health` + service-key-gated `/internal/tick`
- `src/db/missions-projection.ts` — field-level LWW replay of
  `sync_changes` for appId='ai' / table='aiMissions' → live Mission
  records. Mirrors the webapp's `applyServerChanges` semantics against
  Postgres instead of Dexie.
- `src/db/connection.ts` — bounded `postgres.js` pool (max 4, idle 30s)
- `src/cron/tick.ts` — overlap-guarded scheduler, `runTickOnce()` also
  reachable via HTTP for CI/ops triggering
- `src/planner/client.ts` — mana-llm HTTP client shape
  (OpenAI-compatible `/v1/chat/completions`)
- `src/middleware/service-auth.ts` — X-Service-Key gate, no end-user JWTs
  reach this service
- Dockerfile + graceful SIGTERM shutdown (stops timer + releases pool)

Not yet implemented (documented in CLAUDE.md with design trade-offs):
- Prompt/parser server-side copies — today they live in the webapp.
  Recommended next step: extract `@mana/shared-ai` package.
- Input resolvers for notes / kontext / goals — need projections or a
  mana-sync internal endpoint
- Plan → Mission-iteration write-back + how proposals get back to the
  user's device (leaning option (a): server writes iterations, the
  webapp's sync effect translates them into local Proposals)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 23:48:30 +02:00
parent 5c53c6d02e
commit b9710e6c11
11 changed files with 636 additions and 0 deletions

151
services/mana-ai/CLAUDE.md Normal file
View file

@ -0,0 +1,151 @@
# mana-ai
Background runner for the AI Workbench. Picks up due Missions from the `mana_sync` Postgres and plans/proposes next steps without requiring an open browser tab. Complements the foreground `startMissionTick` in the webapp (`apps/mana/apps/web/src/lib/data/ai/missions/setup.ts`).
Design context: [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §20](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md).
## Status: v0.1 (scaffold)
This service is a skeleton. It:
- [x] Boots as a Hono/Bun service on port `3066`
- [x] Exposes `/health` and service-key-gated `/internal/tick`
- [x] Replays `sync_changes` for `appId='ai' / table='aiMissions'` into live Mission records via field-level LWW (`src/db/missions-projection.ts`)
- [x] Lists due missions (`state='active' && nextRunAt <= now()`)
- [x] Has an HTTP client shape for mana-llm (OpenAI-compatible surface)
- [x] Logs every tick's intent ("would plan mission X")
Intentionally **not yet** implemented:
- [ ] Server-side copies of `planner/prompt.ts` + `planner/parser.ts` (today they live in the webapp only)
- [ ] Input-resolvers server-side (needs projections for notes / kontext / goals, or a mana-sync `GET /internal/record/:id` endpoint)
- [ ] Write-back path for plan results (see "Open design questions" below)
- [ ] Per-user Postgres RLS scoping — current read scans cross-user and relies on downstream code honouring `userId`
## Port: 3066
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Runtime** | Bun |
| **Framework** | Hono |
| **Database** | PostgreSQL via `postgres` driver (read-only against `mana_sync`) |
| **Auth** | Service-to-service key; no end-user JWTs |
## Quick Start
```bash
# Requires mana_sync DB reachable
cd services/mana-ai
bun run dev
# Smoke test
curl http://localhost:3066/health
curl -X POST -H "X-Service-Key: dev-service-key" http://localhost:3066/internal/tick
```
## Environment Variables
```env
PORT=3066
SYNC_DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_sync
MANA_LLM_URL=http://localhost:3020
MANA_SERVICE_KEY=dev-service-key
TICK_INTERVAL_MS=60000
TICK_ENABLED=true # flip to false to boot HTTP-only (for Docker health-check)
```
## Architecture
```
┌────────────────────┐
│ mana-ai (Bun) │
│ :3066 │
│ │ 60s interval
│ ┌─────────────┐ │────────────────┐
│ │ tick loop │ │ │
│ │ runTickOnce │ │ │
│ └─────────────┘ │ │
│ │ │ │
│ │ SELECT │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ missions- │ │ │
│ │ projection │ │ │
│ │ (LWW replay)│ │ │
│ └─────────────┘ │ ▼
│ │ ┌──────────────┐
│ ┌─────────────┐ │ │ mana_sync │
│ │ planner │───┼─────────▶│ (Postgres) │
│ │ client │ │ └──────────────┘
│ └─────────────┘ │
│ │ │
└───────┼────────────┘
│ POST /v1/chat/completions
┌────────────────────┐
│ mana-llm (Python) │
│ :3020 │
└────────────────────┘
```
## Open design questions (for next PR)
### 1. How do plan results get back to the user's device?
Proposals live in a **local-only** Dexie table (`pendingProposals`) — they don't sync. So the server can't just write proposals directly.
Options:
**(a) Write iteration + plan to `aiMissions`, let the browser stage proposals on arrival.**
Server appends an iteration with `overallStatus: 'server-planned'` and the plan steps. When the webapp next syncs, an effect subscribed to iteration changes translates each step into a local `Proposal` using the existing `createProposal()`. Clean: preserves the "proposals are local" invariant. Risk: duplicate proposals if multiple devices pick up the same iteration.
**(b) Introduce `aiProposedSteps` as a synced table.**
Server writes here directly; the webapp treats it as a source for its local `pendingProposals`. Requires a migration step + duplicates the proposal model.
**(c) Make `pendingProposals` sync.**
Simplest schema change, most invasive: approvals + rejections now race across devices. Would need server-authoritative state transitions.
**Leaning (a)** — minimal schema change, single source of truth. Implementation sketch: add `iteration.source: 'browser' | 'server'` and a "staging queue" on the webapp that dedups via `iterationId`.
### 2. Does the server need full LWW replay?
The projection replays every `sync_changes` row for `aiMissions` on every tick. For a small user base this is fine; past ~100 users × hundreds of rows it becomes wasteful.
Option: materialized view refreshed on sync-change insert via a trigger or a per-user `ai_mission_snapshot` table the service maintains. Defer until the load shows up.
### 3. Planner prompt: duplicate or share?
`prompt.ts` + `parser.ts` live in the webapp's `@mana/web/src/lib/data/ai/missions/planner/`. Server-side copies would drift. Options:
- Extract a `@mana/shared-ai` package with the prompt/parser
- Keep two copies with a contract test
- Only the webapp plans; server just triggers the browser via push
First is cleanest; TS source, imports cleanly in both Bun and Vite.
## Writing code in here
- No database schema of its own — this service is pure consumer. If you need persistent state (retry queues, per-user cursors), add a separate table namespace under `mana_ai.*` schema on the `mana_sync` database, not a new DB.
- `src/db/missions-projection.ts` is the ONLY place that does LWW replay. Don't duplicate the logic; add new projection helpers there.
- Follow the foreground-runner contract: injected deps (planner, write-back) for tests. Bun's `bun test` runs in `src/**/*.test.ts`.
## Files
```
services/mana-ai/
├── src/
│ ├── index.ts — Hono bootstrap + tick scheduler wiring
│ ├── config.ts — Env loading
│ ├── cron/tick.ts — Scan loop, overlap-guarded
│ ├── db/
│ │ ├── connection.ts — postgres.js pool
│ │ └── missions-projection.ts — sync_changes → Mission LWW replay
│ ├── planner/client.ts — mana-llm HTTP client (OpenAI-compatible)
│ └── middleware/service-auth.ts — X-Service-Key gate for /internal/*
├── Dockerfile
├── package.json
├── tsconfig.json
└── CLAUDE.md
```

View file

@ -0,0 +1,36 @@
# Install stage: use node + pnpm to resolve workspace dependencies
FROM node:22-alpine AS installer
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy workspace structure
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY services/mana-ai/package.json ./services/mana-ai/
COPY packages/shared-hono ./packages/shared-hono
# Install only mana-ai and its workspace deps
RUN pnpm install --filter @mana/ai-service... --no-frozen-lockfile --ignore-scripts
# Runtime stage: bun
FROM oven/bun:1 AS production
WORKDIR /app
COPY --from=installer /app/node_modules ./node_modules
COPY --from=installer /app/services/mana-ai/node_modules ./services/mana-ai/node_modules
COPY --from=installer /app/packages ./packages
COPY services/mana-ai/package.json ./services/mana-ai/
COPY services/mana-ai/src ./services/mana-ai/src
COPY services/mana-ai/tsconfig.json ./services/mana-ai/
WORKDIR /app/services/mana-ai
EXPOSE 3066
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3066/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["bun", "run", "src/index.ts"]

View file

@ -0,0 +1,20 @@
{
"name": "@mana/ai-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"test": "bun test"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",
"hono": "^4.7.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/bun": "latest"
}
}

View file

@ -0,0 +1,42 @@
/**
* Env-driven config for the mana-ai service.
*
* Only references the secrets/URLs the tick loop needs. Auth is
* service-to-service via MANA_SERVICE_KEY (same pattern as mana-credits,
* mana-user); no end-user JWTs reach this service.
*/
export interface Config {
port: number;
/** mana_sync DB — source of Mission rows (via sync_changes replay). */
syncDatabaseUrl: string;
/** mana-llm HTTP endpoint (OpenAI-compatible). */
manaLlmUrl: string;
/** Shared key for service-to-service calls. */
serviceKey: string;
/** How often the background tick scans for due Missions, in ms. */
tickIntervalMs: number;
/** Flip to false to boot the HTTP surface without the background tick
* useful for local smoke-tests + Docker image build verification. */
tickEnabled: boolean;
}
function requireEnv(key: string, fallback?: string): string {
const value = process.env[key] ?? fallback;
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
}
export function loadConfig(): Config {
return {
port: parseInt(process.env.PORT ?? '3066', 10),
syncDatabaseUrl: requireEnv(
'SYNC_DATABASE_URL',
'postgresql://mana:devpassword@localhost:5432/mana_sync'
),
manaLlmUrl: requireEnv('MANA_LLM_URL', 'http://localhost:3020'),
serviceKey: requireEnv('MANA_SERVICE_KEY', 'dev-service-key'),
tickIntervalMs: parseInt(process.env.TICK_INTERVAL_MS ?? '60000', 10),
tickEnabled: process.env.TICK_ENABLED !== 'false',
};
}

View file

@ -0,0 +1,87 @@
/**
* Background tick scans Postgres for due Missions and (eventually) runs
* them through the Planner + writes the resulting plan back as a Mission
* iteration.
*
* Current state (v0.1): reads due missions, logs the intent, does NOT
* write back. Writing requires deciding how proposals materialize
* server-side see `CLAUDE.md` "Open design questions" for the
* trade-offs. Shipping this as a scaffold unblocks:
* - deployability of the service
* - smoke-testing Postgres connectivity + mana-llm reachability
* - next PR wires the actual mission-execution flow
*/
import { getSql } from '../db/connection';
import { listDueMissions } from '../db/missions-projection';
import { PlannerClient } from '../planner/client';
import type { Config } from '../config';
export interface TickStats {
scannedAt: string;
dueMissionCount: number;
errors: string[];
}
let running = false;
/** One tick pass. Idempotent; overlap-guarded at module level. */
export async function runTickOnce(config: Config): Promise<TickStats> {
if (running) {
return { scannedAt: new Date().toISOString(), dueMissionCount: 0, errors: ['overlap-skipped'] };
}
running = true;
const errors: string[] = [];
let dueMissionCount = 0;
const scannedAt = new Date().toISOString();
try {
const sql = getSql(config.syncDatabaseUrl);
const missions = await listDueMissions(sql, scannedAt);
dueMissionCount = missions.length;
if (missions.length === 0) return { scannedAt, dueMissionCount, errors };
// Planner is instantiated here but not invoked yet — see CLAUDE.md.
// The constructor is cheap; holding onto it sets the shape for the
// next PR that actually calls `complete()` per mission.
void new PlannerClient(config.manaLlmUrl, config.serviceKey);
for (const m of missions) {
console.log(
`[mana-ai tick] would plan mission=${m.id} user=${m.userId} title=${JSON.stringify(
m.title
)}`
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(msg);
console.error('[mana-ai tick] error:', msg);
} finally {
running = false;
}
return { scannedAt, dueMissionCount, errors };
}
let handle: ReturnType<typeof setInterval> | null = null;
export function startTick(config: Config): () => void {
if (!config.tickEnabled || handle !== null) return stopTick;
// Kick once immediately so a just-due mission doesn't wait a full interval.
void runTickOnce(config);
handle = setInterval(() => void runTickOnce(config), config.tickIntervalMs);
return stopTick;
}
export function stopTick(): void {
if (handle !== null) {
clearInterval(handle);
handle = null;
}
}
export function isTickRunning(): boolean {
return handle !== null;
}

View file

@ -0,0 +1,31 @@
/**
* Thin `postgres.js` wrapper for the mana_sync database.
*
* We read `sync_changes` rows directly the service is a pure consumer;
* no schema of its own (yet). Missions live as events here, replayed into
* a materialized shape in `missions-projection.ts`.
*/
import postgres from 'postgres';
export type Sql = postgres.Sql<Record<string, never>>;
let _sql: Sql | null = null;
export function getSql(databaseUrl: string): Sql {
if (_sql) return _sql;
_sql = postgres(databaseUrl, {
// Short idle timeout keeps connection count low; the tick is a
// cron-style pattern, not a hot path.
idle_timeout: 30,
max: 4,
});
return _sql;
}
export async function closeSql(): Promise<void> {
if (_sql) {
await _sql.end({ timeout: 5 });
_sql = null;
}
}

View file

@ -0,0 +1,120 @@
/**
* Missions projection replays `sync_changes` rows for appId='ai',
* table='aiMissions' into live Mission records using field-level LWW.
*
* This mirrors what the webapp's `applyServerChanges` does in Dexie but
* runs against Postgres. Kept deliberately dumb: no caching, no
* incremental updates, full replay on every tick. Missions per user are
* bounded (~dozens at most) so O(N) replay is fine for a once-a-minute
* scheduler. Revisit when users hit >1000 missions or the tick misses
* its deadline.
*/
import type { Sql } from './connection';
/**
* Subset of the Mission shape the server needs. Matches
* `apps/mana/apps/web/src/lib/data/ai/missions/types.ts` keep in sync.
*/
export interface ServerMission {
id: string;
userId: string;
title: string;
objective: string;
conceptMarkdown: string;
state: 'active' | 'paused' | 'done' | 'archived';
nextRunAt: string | undefined;
inputs: { module: string; table: string; id: string }[];
cadence: unknown; // opaque — the browser Runner owns cadence math
iterations: unknown[]; // opaque — server just reads count
}
interface ChangeRow {
table_name: string;
record_id: string;
user_id: string;
op: string;
data: Record<string, unknown> | null;
field_timestamps: Record<string, string> | null;
created_at: Date;
}
/**
* Return all currently-active missions whose `nextRunAt` has passed.
* Server-side equivalent of `listMissions({ dueBefore: now })` in the
* webapp store.
*
* @param now ISO timestamp used as the due-before cutoff.
*/
export async function listDueMissions(sql: Sql, now: string): Promise<ServerMission[]> {
// Pull every event for the ai app across users. For a real deploy
// we'd scope per-user or shard; the pre-launch user count makes this
// single scan defensible.
const rows = await sql<ChangeRow[]>`
SELECT table_name, record_id, user_id, op, data, field_timestamps, created_at
FROM sync_changes
WHERE app_id = 'ai' AND table_name = 'aiMissions'
ORDER BY created_at ASC
`;
// Replay per record. Map key: userId::recordId (user isolation is kept
// even though we fetched across users in one scan — the result goes
// back to whichever user owns each row).
const merged = new Map<string, { userId: string; record: Record<string, unknown> }>();
for (const row of rows) {
const key = `${row.user_id}::${row.record_id}`;
const entry = merged.get(key);
if (row.op === 'delete') {
merged.delete(key);
continue;
}
if (!entry) {
if (row.data) {
merged.set(key, { userId: row.user_id, record: { id: row.record_id, ...row.data } });
}
continue;
}
// Field-level LWW: newer timestamps overwrite.
const prevFT = (entry.record.__fieldTimestamps as Record<string, string> | undefined) ?? {};
const nextFT = { ...prevFT };
if (row.data) {
for (const [k, v] of Object.entries(row.data)) {
const serverTime = row.field_timestamps?.[k] ?? row.created_at.toISOString();
const localTime = prevFT[k] ?? '';
if (serverTime >= localTime) {
entry.record[k] = v;
nextFT[k] = serverTime;
}
}
}
entry.record.__fieldTimestamps = nextFT;
}
const missions: ServerMission[] = [];
for (const { userId, record } of merged.values()) {
const state = record.state as ServerMission['state'];
const nextRunAt = record.nextRunAt as string | undefined;
const deletedAt = record.deletedAt as string | undefined;
if (deletedAt) continue;
if (state !== 'active') continue;
if (!nextRunAt || nextRunAt > now) continue;
missions.push({
id: String(record.id),
userId,
title: String(record.title ?? ''),
objective: String(record.objective ?? ''),
conceptMarkdown: String(record.conceptMarkdown ?? ''),
state,
nextRunAt,
inputs: Array.isArray(record.inputs) ? (record.inputs as ServerMission['inputs']) : [],
cadence: record.cadence,
iterations: Array.isArray(record.iterations) ? record.iterations : [],
});
}
return missions;
}

View file

@ -0,0 +1,59 @@
/**
* mana-ai background Mission Runner for the AI Workbench.
*
* Hono + Bun service that scans mana_sync's `sync_changes` table for due
* Missions (see `data/ai/missions/` in the webapp), calls mana-llm for
* planning, and will eventually stage the resulting plan back as mission
* iterations so the webapp renders them as proposals on next sync.
*
* This is v0.1: scaffold + readable due-mission scan + planner-client
* shape. Execution write-back is tracked as the next PR see CLAUDE.md.
*/
import { Hono } from 'hono';
import { loadConfig } from './config';
import { closeSql } from './db/connection';
import { runTickOnce, startTick, stopTick, isTickRunning } from './cron/tick';
import { serviceAuth } from './middleware/service-auth';
const config = loadConfig();
const app = new Hono();
app.get('/health', (c) =>
c.json({
ok: true,
service: 'mana-ai',
version: '0.1.0',
tick: { enabled: config.tickEnabled, running: isTickRunning() },
})
);
// Service-to-service: manually fire a tick for CI / ops / debugging
// without waiting for the interval.
app.use('/internal/*', serviceAuth(config.serviceKey));
app.post('/internal/tick', async (c) => {
const stats = await runTickOnce(config);
return c.json(stats);
});
const stopScheduledTick = startTick(config);
const server = Bun.serve({
port: config.port,
fetch: app.fetch,
});
console.log(`[mana-ai] listening on :${config.port} (tick=${config.tickEnabled ? 'on' : 'off'})`);
// Graceful shutdown — release DB + timer so SIGTERM in k8s shuts down
// the pod instead of waiting for SIGKILL after the grace period.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.on(signal, async () => {
console.log(`[mana-ai] ${signal} received — shutting down`);
stopScheduledTick();
server.stop();
await closeSql();
process.exit(0);
});
}

View file

@ -0,0 +1,16 @@
/**
* Service-to-service auth: validates X-Service-Key against MANA_SERVICE_KEY.
* All admin routes on this service are service-only no end-user JWTs.
*/
import type { MiddlewareHandler } from 'hono';
export function serviceAuth(serviceKey: string): MiddlewareHandler {
return async (c, next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== serviceKey) {
return c.json({ error: 'invalid or missing service key' }, 401);
}
await next();
};
}

View file

@ -0,0 +1,56 @@
/**
* Thin HTTP client for mana-llm (OpenAI-compatible surface on /v1/chat/completions).
*
* The prompt/parser logic lives in the webapp's
* `apps/mana/apps/web/src/lib/data/ai/missions/planner/` directory and is
* duplicated here as server-side copies in follow-up work keeping the
* webapp as source of truth for now while the service matures.
*/
export interface PlannerMessages {
system: string;
user: string;
}
export interface PlannerResult {
/** Raw text the LLM returned. Parser lives alongside the caller. */
content: string;
}
export class PlannerClient {
constructor(
private readonly baseUrl: string,
private readonly serviceKey: string
) {}
async complete(
messages: PlannerMessages,
opts: { model?: string; temperature?: number } = {}
): Promise<PlannerResult> {
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${this.serviceKey}`,
},
body: JSON.stringify({
model: opts.model ?? 'gpt-4o-mini',
temperature: opts.temperature ?? 0.3,
messages: [
{ role: 'system', content: messages.system },
{ role: 'user', content: messages.user },
],
}),
});
if (!res.ok) {
throw new Error(`mana-llm ${res.status}: ${await res.text().catch(() => '')}`);
}
const body = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = body.choices?.[0]?.message?.content ?? '';
return { content };
}
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"types": ["bun-types"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"]
}