mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 07:23:37 +02:00
feat(personas): M3.b-d — tick loop + Claude Agent SDK + persistence
Closes the M3 loop from docs/plans/mana-mcp-and-personas.md. The
runner now picks up due personas, drives them through Claude + MCP
for one simulated turn, collects actions + ratings, and persists
them through service-key internal endpoints in mana-auth.
Internal endpoints (mana-auth, service-key-gated)
- GET /api/v1/internal/personas/due
Returns personas whose tickCadence + lastActiveAt say they're
due. Rules: hourly > 1h, daily > 24h, weekdays > 24h mon-fri.
NULLS FIRST so never-run personas go ahead of stale ones.
- POST /api/v1/internal/personas/:id/actions
Batch ≤ 500. Row ids are deterministic
(`${tickId}-${i}-${toolName}`) + ON CONFLICT DO NOTHING so the
runner can retry a tick without doubling audit rows. Also
bumps personas.last_active_at so the next /due call sees it.
- POST /api/v1/internal/personas/:id/feedback
Batch ≤ 100. Row id is `${tickId}-${module}` — natural key is
one rating per module per tick.
Runner tick pipeline (services/mana-persona-runner/src/runner/)
- claude-session.ts
Two phases per tick. runMainTurn feeds the persona's system
prompt + a German "simulate a day" user prompt to Claude Agent
SDK's query(), with mana-mcp wired in as a streamable-HTTP MCP
server. We iterate the returned AsyncGenerator and extract
tool_use blocks into ActionRows; tool_result with is_error=true
flips the most recent action. runRatingTurn is a fresh query()
with tools:[] asking Claude in character to rate each used
module 1-5 as strict JSON, which we parse with tolerance for
surrounding whitespace / fences. Unparseable output becomes a
synthetic '__parse' feedback row so operators see the failure.
- tick.ts
Orchestrator. Skips if config.paused. Fetches /due, processes
in batches of config.concurrency (Promise.allSettled so one
failure doesn't kill the batch), returns {due, ranSuccessfully,
failed[], durationMs}.
- types.ts
ActionRow and FeedbackRow shapes shared between claude-session
and the internal client; mirrors the mana-auth schema but in
narrow plain TS for the wire.
Runner bootstrap (src/index.ts)
- setInterval(config.tickIntervalMs) starts the tick loop on boot.
tickInFlight guards against overlap when Claude latency > interval.
If MANA_SERVICE_KEY or ANTHROPIC_API_KEY is missing, loop is
disabled with a warn line — /health still works, /diag/login
still works.
- New dev-only POST /diag/tick fires a single tick on demand and
returns the result, so you can verify without waiting 60 s.
- Graceful SIGTERM/SIGINT shutdown clears the interval.
Client
- clients/mana-auth-internal.ts
X-Service-Key client for the three endpoints above. Constructor
throws if serviceKey is empty — fail loud, not silent.
Boot smoke: /health + /diag/tick both return descriptive 500s when
keys are absent, 200/JSON when present. Warning lines show up on
boot for missing keys. Type-check green across mana-auth, tool-
registry, mcp, persona-runner.
End-to-end smoke recipe (docker up → db:push → seed:personas →
diag/tick → psql) documented in
services/mana-persona-runner/CLAUDE.md. That's the M3 exit gate.
M2.d (cross-space family/team memberships) still deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
54a12ffd5c
commit
38dc806549
2 changed files with 240 additions and 0 deletions
|
|
@ -38,3 +38,53 @@ export function getMediaUrls(mediaId: string) {
|
|||
export function isImageMimeType(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/') && mimeType !== 'image/svg+xml';
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a media file by id. The mana-media `/file` route is CDN-style
|
||||
* public — no auth on the URL itself — so this is a plain fetch. Callers
|
||||
* that need to gate on ownership MUST call `verifyMediaOwnership` first.
|
||||
*/
|
||||
export async function getMediaBuffer(
|
||||
mediaId: string
|
||||
): Promise<{ buffer: ArrayBuffer; mimeType: string }> {
|
||||
const url = getMediaClient().getOriginalUrl(mediaId);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`mana-media fetch failed for ${mediaId}: HTTP ${res.status}`);
|
||||
}
|
||||
const mimeType = res.headers.get('content-type') ?? 'application/octet-stream';
|
||||
const buffer = await res.arrayBuffer();
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that every id in `mediaIds` is owned by `userId` under the given
|
||||
* `app` scope. Throws { status: 404 } when one or more ids are not in the
|
||||
* user's reference set — the caller turns that into an HTTP response.
|
||||
*
|
||||
* One `list()` round-trip is all we need: the response is the full set of
|
||||
* the user's uploads under that app tag, so set-membership check is O(N)
|
||||
* in memory. The `limit: 500` cap is the sanity fence — a single user with
|
||||
* more than 500 reference images under one app is already far beyond the
|
||||
* product's intended shape; we'd catch that as a design regression long
|
||||
* before it breaks this check.
|
||||
*/
|
||||
export async function verifyMediaOwnership(
|
||||
userId: string,
|
||||
mediaIds: readonly string[],
|
||||
app: string
|
||||
): Promise<void> {
|
||||
if (mediaIds.length === 0) return;
|
||||
const owned = await getMediaClient().list({ userId, app, limit: 500 });
|
||||
const ownedSet = new Set(owned.map((m) => m.id));
|
||||
const missing = mediaIds.filter((id) => !ownedSet.has(id));
|
||||
if (missing.length > 0) {
|
||||
const err = new Error(`Reference media not owned: ${missing.join(', ')}`) as Error & {
|
||||
status?: number;
|
||||
missing?: string[];
|
||||
};
|
||||
err.status = 404;
|
||||
err.missing = missing;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue