mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
5c53c6d02e
commit
b9710e6c11
11 changed files with 636 additions and 0 deletions
151
services/mana-ai/CLAUDE.md
Normal file
151
services/mana-ai/CLAUDE.md
Normal 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
|
||||
```
|
||||
36
services/mana-ai/Dockerfile
Normal file
36
services/mana-ai/Dockerfile
Normal 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"]
|
||||
20
services/mana-ai/package.json
Normal file
20
services/mana-ai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
42
services/mana-ai/src/config.ts
Normal file
42
services/mana-ai/src/config.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
87
services/mana-ai/src/cron/tick.ts
Normal file
87
services/mana-ai/src/cron/tick.ts
Normal 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;
|
||||
}
|
||||
31
services/mana-ai/src/db/connection.ts
Normal file
31
services/mana-ai/src/db/connection.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
120
services/mana-ai/src/db/missions-projection.ts
Normal file
120
services/mana-ai/src/db/missions-projection.ts
Normal 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;
|
||||
}
|
||||
59
services/mana-ai/src/index.ts
Normal file
59
services/mana-ai/src/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
16
services/mana-ai/src/middleware/service-auth.ts
Normal file
16
services/mana-ai/src/middleware/service-auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
56
services/mana-ai/src/planner/client.ts
Normal file
56
services/mana-ai/src/planner/client.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
18
services/mana-ai/tsconfig.json
Normal file
18
services/mana-ai/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue