mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
faa472be91
commit
a1caeaa7f3
9 changed files with 422 additions and 2 deletions
91
services/mana-persona-runner/CLAUDE.md
Normal file
91
services/mana-persona-runner/CLAUDE.md
Normal 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 (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.
|
||||
23
services/mana-persona-runner/package.json
Normal file
23
services/mana-persona-runner/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
services/mana-persona-runner/src/clients/auth.ts
Normal file
98
services/mana-persona-runner/src/clients/auth.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
86
services/mana-persona-runner/src/config.ts
Normal file
86
services/mana-persona-runner/src/config.ts
Normal 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(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
77
services/mana-persona-runner/src/index.ts
Normal file
77
services/mana-persona-runner/src/index.ts
Normal 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,
|
||||
};
|
||||
23
services/mana-persona-runner/src/password.ts
Normal file
23
services/mana-persona-runner/src/password.ts
Normal 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);
|
||||
}
|
||||
19
services/mana-persona-runner/tsconfig.json
Normal file
19
services/mana-persona-runner/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue