mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
/**
|
|
* Shared media helper — routes image uploads through mana-media
|
|
* for CAS deduplication, thumbnail generation, and Photos gallery visibility.
|
|
*/
|
|
|
|
import { MediaClient, type MediaResult } from '@mana/media-client';
|
|
|
|
const MEDIA_URL = process.env.MANA_MEDIA_URL || 'http://localhost:3015';
|
|
let client: MediaClient | null = null;
|
|
|
|
function getMediaClient(): MediaClient {
|
|
if (!client) client = new MediaClient(MEDIA_URL);
|
|
return client;
|
|
}
|
|
|
|
export async function uploadImageToMedia(
|
|
buffer: ArrayBuffer,
|
|
filename: string,
|
|
options: { app: string; userId: string }
|
|
): Promise<MediaResult> {
|
|
return getMediaClient().upload(buffer, {
|
|
app: options.app,
|
|
userId: options.userId,
|
|
filename,
|
|
});
|
|
}
|
|
|
|
export function getMediaUrls(mediaId: string) {
|
|
const c = getMediaClient();
|
|
return {
|
|
original: c.getOriginalUrl(mediaId),
|
|
thumbnail: c.getThumbnailUrl(mediaId),
|
|
medium: c.getMediumUrl(mediaId),
|
|
large: c.getLargeUrl(mediaId),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|