feat(personas): M3.a — scaffold mana-persona-runner service on :3070

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:00:43 +02:00
parent faa472be91
commit a1caeaa7f3
9 changed files with 422 additions and 2 deletions

View file

@ -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 (15 + 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.

View file

@ -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"
}
}

View file

@ -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<LoginResult> {
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(() => '<unreadable>');
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<Space[]> {
const res = await fetch(`${this.authUrl}/api/auth/organization/list`, {
headers: { authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
const body = await res.text().catch(() => '<unreadable>');
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 };
}
}

View file

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

View file

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

View file

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

View file

@ -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"]
}