From a1caeaa7f374054a8a8191622a230e44959855e0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 14:00:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(personas):=20M3.a=20=E2=80=94=20scaffold?= =?UTF-8?q?=20mana-persona-runner=20service=20on=20:3070?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First concrete piece of M3 (docs/plans/mana-mcp-and-personas.md). The tick loop itself and the Claude Agent SDK + MCP integration are M3.b; the action/feedback persistence endpoints are M3.c. This commit just stands up the service so the remaining pieces have a shell to land in. Service shape (Bun/Hono on :3070) - src/config.ts Env-driven configuration: auth URL, MCP URL, service key for action/feedback callbacks (M3.c), Anthropic API key, deterministic PERSONA_SEED_SECRET (must match scripts/personas/password.ts so the runner can log back in without any stored credentials), tick interval and concurrency, RUNNER_PAUSED kill-switch. Production start asserts all secrets are set and the dev fallback secret is rotated. - src/password.ts Bit-for-bit identical HMAC-SHA256 password derivation to scripts/personas/password.ts. Duplicated deliberately: the two sides can't share code (one is a repo-root utility script, the other is a workspace service) but must stay in sync — comment at the top calls this out. - src/clients/auth.ts Two upstream calls the runner needs for one tick: POST /auth/login and GET /api/auth/organization/list. loginAndResolvePersonalSpace() wraps both and picks the persona's auto-created personal space as the write target (throws if none exists — Spaces-Foundation should always have seeded one on signup). - src/index.ts Hono app: /health, /metrics (stub), and a dev-only /diag/login endpoint that takes a persona email, derives the password, logs in, resolves the personal space, and returns {userId, spaceId} as an end-to-end sanity check. Disabled in production. No tick loop yet — RUNNER_PAUSED prints an info line on boot, but nothing fires. The dispatcher + Claude Agent SDK + MCP client land in M3.b; the internal POST callbacks into mana-auth for persona_actions / persona_feedback land in M3.c. Infra - Port 3070 added to docs/PORT_SCHEMA.md. - Service listed in root CLAUDE.md next to mana-mcp. - services/mana-persona-runner/CLAUDE.md documents what's built today, what lands in M3.b/c, and the local diag smoke recipe. Boot smoke verified: /health returns ok + paused/interval/concurrency, /diag/login without email returns 400. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- docs/PORT_SCHEMA.md | 5 +- services/mana-persona-runner/CLAUDE.md | 91 +++++++++++++++++ services/mana-persona-runner/package.json | 23 +++++ .../mana-persona-runner/src/clients/auth.ts | 98 +++++++++++++++++++ services/mana-persona-runner/src/config.ts | 86 ++++++++++++++++ services/mana-persona-runner/src/index.ts | 77 +++++++++++++++ services/mana-persona-runner/src/password.ts | 23 +++++ services/mana-persona-runner/tsconfig.json | 19 ++++ 9 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 services/mana-persona-runner/CLAUDE.md create mode 100644 services/mana-persona-runner/package.json create mode 100644 services/mana-persona-runner/src/clients/auth.ts create mode 100644 services/mana-persona-runner/src/config.ts create mode 100644 services/mana-persona-runner/src/index.ts create mode 100644 services/mana-persona-runner/src/password.ts create mode 100644 services/mana-persona-runner/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index ee8054f1a..846741ddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ docs/ # Long-form docs (deployment, hardware, postmortems, etc.) ### Active services (`services/`) -`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)), `mana-mcp` (3069, MCP gateway exposing the shared tool-registry to Claude Desktop / Claude Code / persona-runner — see [`services/mana-mcp/CLAUDE.md`](services/mana-mcp/CLAUDE.md) and [`docs/plans/mana-mcp-and-personas.md`](docs/plans/mana-mcp-and-personas.md)). Each non-trivial service has its own `CLAUDE.md`. +`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)), `mana-mcp` (3069, MCP gateway exposing the shared tool-registry to Claude Desktop / Claude Code / persona-runner — see [`services/mana-mcp/CLAUDE.md`](services/mana-mcp/CLAUDE.md) and [`docs/plans/mana-mcp-and-personas.md`](docs/plans/mana-mcp-and-personas.md)), `mana-persona-runner` (3070, drives M2 personas through Claude + MCP on a tick loop — see [`services/mana-persona-runner/CLAUDE.md`](services/mana-persona-runner/CLAUDE.md)). Each non-trivial service has its own `CLAUDE.md`. ## Coding Guidelines diff --git a/docs/PORT_SCHEMA.md b/docs/PORT_SCHEMA.md index cc3b6090b..f73f9bf52 100644 --- a/docs/PORT_SCHEMA.md +++ b/docs/PORT_SCHEMA.md @@ -29,7 +29,10 @@ > mana-analytics `3064`, mana-events `3065`, mana-research `3068` > (new 2026-04-17, Bun/Hono, public: `research.mana.how`), > mana-mcp `3069` (new 2026-04-22, Bun/Hono, MCP gateway over -> Streamable HTTP — see `services/mana-mcp/CLAUDE.md`) +> Streamable HTTP — see `services/mana-mcp/CLAUDE.md`), +> mana-persona-runner `3070` (new 2026-04-22, Bun/Hono, drives +> M2 personas through Claude + MCP on a tick loop — see +> `services/mana-persona-runner/CLAUDE.md`) > > **Not deployed:** `mana-voice-bot` (default port `3024`, no scheduled > task, no cloudflared route, no launchd plist). diff --git a/services/mana-persona-runner/CLAUDE.md b/services/mana-persona-runner/CLAUDE.md new file mode 100644 index 000000000..871fcba13 --- /dev/null +++ b/services/mana-persona-runner/CLAUDE.md @@ -0,0 +1,91 @@ +# mana-persona-runner + +Tick-loop service that drives the **M2 personas** through the app via Claude + the **mana-mcp** gateway. Test infrastructure — not a user-facing service, not deployed to prod until the runner has proven itself in staging. + +**Plan:** [`docs/plans/mana-mcp-and-personas.md`](../../docs/plans/mana-mcp-and-personas.md) (M3) + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Runtime** | Bun | +| **Framework** | Hono | +| **AI** | `@anthropic-ai/claude-agent-sdk` (native MCP tool-loop) | +| **Tools** | `mana-mcp` (`:3069`) — Streamable HTTP, per-persona JWT | +| **Upstream** | `mana-auth` (`:3001`) for login + spaces + action/feedback persistence | + +## Port: 3070 + +## What it does (when the tick loop lands — M3.b) + +Every `TICK_INTERVAL_MS`: + +1. Query `auth.personas` for rows whose `tickCadence` + `lastActiveAt` make them due. +2. Limit to `PERSONA_CONCURRENCY` personas in parallel. +3. For each due persona: + - **Login**: `POST /api/v1/auth/login` with deterministic HMAC-derived password (same algorithm as `scripts/personas/password.ts`). + - **Resolve space**: `GET /api/auth/organization/list`, pick first `personal` space. + - **Claude call**: `@anthropic-ai/claude-agent-sdk` with `persona.systemPrompt`, MCP server wired to `:3069`, `X-Mana-Space` pinned to the persona's personal space. + - **Self-reflection**: after the tool loop settles, ask Claude in-character to rate each module used (1–5 + note). + - **Persist**: `POST /api/v1/internal/personas/:id/actions` and `/feedback` on mana-auth (service-key auth). + +## What M3.a ships (2026-04-22) + +Scaffold only — enough to prove the service boots, speaks to mana-auth, and can log in as a persona end-to-end. + +- `src/config.ts` — env-driven config + production-secret assertion +- `src/clients/auth.ts` — login + listSpaces, convenience `loginAndResolvePersonalSpace` +- `src/password.ts` — HMAC derivation (mirror of `scripts/personas/password.ts`, see comment) +- `src/index.ts` — Hono app, `/health`, `/metrics`, dev-only `/diag/login` + +**Not yet built:** tick dispatcher, Claude Agent SDK integration, MCP client wiring, action/feedback callbacks. Those land in M3.b + M3.c. + +## Environment Variables + +```env +PORT=3070 +MANA_AUTH_URL=http://localhost:3001 +MANA_MCP_URL=http://localhost:3069 + +# Service-to-service auth for action/feedback persistence (M3.c). +MANA_SERVICE_KEY=... + +# Claude API key the runner uses to drive each persona's turn. +ANTHROPIC_API_KEY=... + +# Must match whatever the seed script used when the personas were created. +# In production: rotate together with the seed script's env. +PERSONA_SEED_SECRET=... + +# Tick loop (M3.b). +TICK_INTERVAL_MS=60000 +PERSONA_CONCURRENCY=2 + +# Operational kill-switch. When true, the service stays up (health-ok) +# but no ticks fire. Useful during demos or when debugging a persona. +RUNNER_PAUSED=false +``` + +## Local diag smoke + +Once mana-auth + a seeded persona exist: + +```bash +# Start the stack +pnpm docker:up +pnpm dev:auth # mana-auth on 3001 +pnpm --filter @mana/mcp-service dev # mana-mcp on 3069 +pnpm --filter @mana/persona-runner dev # this service on 3070 + +# From a second shell, once `pnpm seed:personas` has run: +curl -s "localhost:3070/diag/login?email=persona.anna@mana.test" | jq +# → { ok: true, email: "persona.anna@mana.test", userId: "…", spaceId: "…" } +``` + +A successful diag call proves: password derivation matches the seed script, mana-auth login works, the personal space auto-created at signup is discoverable. + +## Why a separate service (not part of mana-ai) + +- **Lifecycle**: persona-runner is test infra. Starts and stops with a demo, can be paused without downtime noise. mana-ai is a production worker for real user missions — different risk profile. +- **Observability**: mixing them means "is this tick Anna running the suite or a real user running their mission?" becomes a log-filter problem. Separate services give you separate Prometheus scrapes. +- **Tool source**: mana-ai today uses an internal tool catalog; persona-runner uses MCP. When M4 unifies both onto `@mana/tool-registry`, the split still makes sense as two consumers of the same tool surface. diff --git a/services/mana-persona-runner/package.json b/services/mana-persona-runner/package.json new file mode 100644 index 000000000..a24c114e2 --- /dev/null +++ b/services/mana-persona-runner/package.json @@ -0,0 +1,23 @@ +{ + "name": "@mana/persona-runner", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Tick-loop service that drives the M2 personas: for each due persona, logs in, opens an MCP session against mana-mcp, asks Claude to role-play that persona through the app for one turn, then posts actions + ratings back to mana-auth.", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.118", + "@mana/shared-hono": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "hono": "^4.7.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/bun": "^1.1.16", + "typescript": "^5.9.3" + } +} diff --git a/services/mana-persona-runner/src/clients/auth.ts b/services/mana-persona-runner/src/clients/auth.ts new file mode 100644 index 000000000..87e848583 --- /dev/null +++ b/services/mana-persona-runner/src/clients/auth.ts @@ -0,0 +1,98 @@ +/** + * mana-auth HTTP client — just the two calls the runner needs to play + * one tick: log in as the persona, list their spaces to pick a target. + * + * The service-to-service callbacks for action/feedback persistence live + * in `./mana-auth-internal.ts`; they use a different auth mechanism + * (service key) and shouldn't share code paths with the user-JWT login + * flow here. + */ + +export interface LoginResult { + token: string; + userId: string; +} + +export interface Space { + id: string; + name: string; + type?: 'personal' | 'brand' | 'club' | 'family' | 'team' | 'practice'; +} + +export class AuthClient { + constructor(private readonly authUrl: string) {} + + async login(email: string, password: string): Promise { + const res = await fetch(`${this.authUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error( + `Persona login failed for ${email}: HTTP ${res.status} — ${body.slice(0, 300)}` + ); + } + const body = (await res.json()) as { + token?: string; + user?: { id?: string }; + }; + if (!body.token || !body.user?.id) { + throw new Error(`Persona login response missing token/user.id for ${email}`); + } + return { token: body.token, userId: body.user.id }; + } + + /** + * Lists every space the caller is a member of. Used by the runner + * to pick the persona's personal space as the write target — the + * first space with `type='personal'` wins. + */ + async listSpaces(jwt: string): Promise { + const res = await fetch(`${this.authUrl}/api/auth/organization/list`, { + headers: { authorization: `Bearer ${jwt}` }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Space list failed: HTTP ${res.status} — ${body.slice(0, 300)}`); + } + const raw = (await res.json()) as Array<{ + id: string; + name: string; + metadata?: { type?: Space['type'] } | string | null; + }>; + return raw.map((row) => { + let type: Space['type'] | undefined; + if (typeof row.metadata === 'string') { + try { + type = (JSON.parse(row.metadata) as { type?: Space['type'] }).type; + } catch { + type = undefined; + } + } else if (row.metadata && typeof row.metadata === 'object') { + type = row.metadata.type; + } + return { id: row.id, name: row.name, type }; + }); + } + + /** + * Convenience: login, fetch spaces, return the personal space. + * Throws if the persona has no personal space (shouldn't happen — + * Spaces-Foundation auto-creates one on signup, but we fail loud if + * it does). + */ + async loginAndResolvePersonalSpace( + email: string, + password: string + ): Promise<{ jwt: string; userId: string; spaceId: string }> { + const { token, userId } = await this.login(email, password); + const spaces = await this.listSpaces(token); + const personal = spaces.find((s) => s.type === 'personal') ?? spaces[0]; + if (!personal) { + throw new Error(`Persona ${email} has no spaces — signup flow did not auto-create one?`); + } + return { jwt: token, userId, spaceId: personal.id }; + } +} diff --git a/services/mana-persona-runner/src/config.ts b/services/mana-persona-runner/src/config.ts new file mode 100644 index 000000000..545cbd298 --- /dev/null +++ b/services/mana-persona-runner/src/config.ts @@ -0,0 +1,86 @@ +/** + * Service configuration. Every value env-overridable so dev/staging/prod + * can dial them independently without a code change. + */ + +export interface Config { + port: number; + + /** mana-auth base URL — for login, spaces list, persistence callbacks. */ + authUrl: string; + /** mana-mcp base URL — where Claude talks to the tool registry. */ + mcpUrl: string; + + /** Service key for /api/v1/internal/* callbacks into mana-auth. */ + serviceKey: string; + + /** Anthropic API key that drives each persona's Claude call. */ + anthropicApiKey: string; + + /** Deterministic per-persona password seed. Must match whatever is + * in scripts/personas/password.ts — the same function derives the + * same password here, so the runner can log in without storing any + * per-persona credentials. */ + personaSeedSecret: string; + + /** How often the tick loop runs. Default 60 s — fine-grained enough + * that a persona with tickCadence='hourly' stays on schedule, cheap + * enough that dispatching 10 personas never backs up. */ + tickIntervalMs: number; + + /** Max personas running in parallel per tick. Scoped to Claude API + * rate limits — conservative default 2, tier-dependent. */ + concurrency: number; + + /** + * Pause the loop without redeploying. Useful when the user wants + * persona activity to stop (e.g. during a demo) but the service + * should stay up so health checks don't page. + */ + paused: boolean; +} + +function intEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${name} must be a positive integer, got "${raw}"`); + } + return n; +} + +function boolEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (raw == null) return fallback; + return raw === '1' || raw.toLowerCase() === 'true'; +} + +export function loadConfig(): Config { + return { + port: intEnv('PORT', 3070), + authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001', + mcpUrl: process.env.MANA_MCP_URL ?? 'http://localhost:3069', + serviceKey: process.env.MANA_SERVICE_KEY ?? '', + anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', + personaSeedSecret: process.env.PERSONA_SEED_SECRET ?? 'dev-persona-seed-secret-rotate-in-prod', + tickIntervalMs: intEnv('TICK_INTERVAL_MS', 60_000), + concurrency: intEnv('PERSONA_CONCURRENCY', 2), + paused: boolEnv('RUNNER_PAUSED', false), + }; +} + +export function assertProductionSecrets(config: Config): void { + if (process.env.NODE_ENV !== 'production') return; + const missing: string[] = []; + if (!config.serviceKey) missing.push('MANA_SERVICE_KEY'); + if (!config.anthropicApiKey) missing.push('ANTHROPIC_API_KEY'); + if (config.personaSeedSecret === 'dev-persona-seed-secret-rotate-in-prod') { + missing.push('PERSONA_SEED_SECRET (dev fallback in prod)'); + } + if (missing.length > 0) { + throw new Error( + `mana-persona-runner production start blocked — missing/unsafe: ${missing.join(', ')}` + ); + } +} diff --git a/services/mana-persona-runner/src/index.ts b/services/mana-persona-runner/src/index.ts new file mode 100644 index 000000000..8b573646e --- /dev/null +++ b/services/mana-persona-runner/src/index.ts @@ -0,0 +1,77 @@ +/** + * mana-persona-runner — tick-loop service that drives persona accounts + * through Claude + MCP. + * + * Scope of this file today (M3.a scaffold): + * - bootstrap Hono on :3070 + * - /health, /metrics stub, /diag/login (dev-only: prove login works) + * - no tick loop yet — that lands in M3.b, along with the Claude + MCP + * integration + * + * Plan: docs/plans/mana-mcp-and-personas.md (M3). + */ + +import { Hono } from 'hono'; +import { AuthClient } from './clients/auth.ts'; +import { loadConfig, assertProductionSecrets } from './config.ts'; +import { personaPassword } from './password.ts'; + +const config = loadConfig(); +assertProductionSecrets(config); + +const authClient = new AuthClient(config.authUrl); + +const app = new Hono(); + +// ─── Health / metrics ───────────────────────────────────────────── + +app.get('/health', (c) => + c.json({ + status: 'ok', + service: 'mana-persona-runner', + paused: config.paused, + tickIntervalMs: config.tickIntervalMs, + concurrency: config.concurrency, + }) +); + +app.get('/metrics', (c) => + c.text('# mana-persona-runner metrics stub — populated alongside the tick loop in M3.b\n') +); + +// ─── Dev diagnostics ────────────────────────────────────────────── +// +// `/diag/login?email=persona.anna@mana.test` lets a developer verify +// the password derivation + mana-auth wiring end-to-end without +// spinning up the full tick loop. Only responds in non-production. + +app.get('/diag/login', async (c) => { + if (process.env.NODE_ENV === 'production') { + return c.json({ error: 'diagnostics disabled in production' }, 404); + } + const email = c.req.query('email'); + if (!email) return c.json({ error: 'email query required' }, 400); + try { + const password = personaPassword(email, config.personaSeedSecret); + const { userId, spaceId } = await authClient.loginAndResolvePersonalSpace(email, password); + return c.json({ ok: true, email, userId, spaceId }); + } catch (err) { + return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 500); + } +}); + +// ─── Server ─────────────────────────────────────────────────────── + +console.info( + `[mana-persona-runner] listening on :${config.port} ` + + `(auth=${config.authUrl} mcp=${config.mcpUrl} paused=${config.paused})` +); + +if (config.paused) { + console.info('[mana-persona-runner] loop is PAUSED via RUNNER_PAUSED — health-only mode'); +} + +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/mana-persona-runner/src/password.ts b/services/mana-persona-runner/src/password.ts new file mode 100644 index 000000000..5b3ea670f --- /dev/null +++ b/services/mana-persona-runner/src/password.ts @@ -0,0 +1,23 @@ +/** + * Deterministic per-persona password derivation. + * + * MUST stay bit-identical to `scripts/personas/password.ts` — both + * sides (seed script that creates the persona, runner that logs back + * in as them) derive the same secret from the same inputs. Changing + * the algorithm here without changing it there locks the runner out + * of every existing persona. + * + * Algorithm: + * HMAC-SHA256(PERSONA_SEED_SECRET, email) → base64 → strip + * non-alphanumeric → first 32 chars. + */ + +import { createHmac } from 'node:crypto'; + +export function personaPassword(email: string, seedSecret: string): string { + if (!seedSecret) { + throw new Error('personaPassword: seedSecret is required'); + } + const hmac = createHmac('sha256', seedSecret).update(email).digest('base64'); + return hmac.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32); +} diff --git a/services/mana-persona-runner/tsconfig.json b/services/mana-persona-runner/tsconfig.json new file mode 100644 index 000000000..549aa70bd --- /dev/null +++ b/services/mana-persona-runner/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022"], + "types": ["bun"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}