managarten/apps/api/src/lib/media.ts
Till JS 38dc806549 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>
2026-04-23 14:16:36 +02:00

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