managarten/packages/shared-ai/src/actor.ts
Till JS 1771063df4 refactor(actor): identity-aware Actor for Multi-Agent Workbench (Phase 1)
Foundation for the Multi-Agent Workbench roadmap
(docs/plans/multi-agent-workbench.md). Every event, record, and
sync_changes row now carries a principal identity + cached display
name in addition to the three-kind discriminator.

Shape change (source of truth in @mana/shared-ai):
  Before: { kind: 'user' | 'ai' | 'system', ...kind-specific fields }
  After:  discriminated union on kind, with
            - common:  principalId, displayName
            - 'user':  principalId = userId
            - 'ai':    principalId = agentId + missionId/iterationId/rationale
            - 'system': principalId = one of SYSTEM_* sentinel strings
                        ('system:projection', 'system:mission-runner', etc.)

Key design calls (from the plan's Q&A):
- System sub-sources get distinct principalIds (not a shared 'system'
  bucket) — lets Workbench filter + revert distinguish projection
  writes from migration writes from server-iteration writes
- displayName cached on the record so renaming an agent doesn't
  rewrite history
- normalizeActor() compat shim fills principalId/displayName on
  legacy rows with 'legacy:*' sentinels so historical events never
  crash the timeline

New exports:
- BaseActor / UserActor / AiActor / SystemActor (narrowed types)
- makeUserActor, makeAgentActor, makeSystemActor (factories with
  typed return)
- SYSTEM_PROJECTION, SYSTEM_RULE, SYSTEM_MIGRATION, SYSTEM_STREAM,
  SYSTEM_MISSION_RUNNER (principalId constants)
- LEGACY_USER_PRINCIPAL, LEGACY_AI_PRINCIPAL, LEGACY_SYSTEM_PRINCIPAL
- isUserActor / isFromMissionRunner predicates

Webapp:
- data/events/actor.ts now re-exports from shared-ai, keeps runtime
  ambient-context (runAs, getCurrentActor) local
- bindDefaultUser(userId, displayName) lets the auth layer replace
  the legacy placeholder with the real logged-in user actor at login
- Mission runner + server-iteration-staging stamp LEGACY_AI_PRINCIPAL
  as the agentId placeholder — Phase 2 will thread the real agent
- Streaks projection uses makeSystemActor(SYSTEM_PROJECTION)
- All test fixtures migrated to factories

Service:
- mana-ai/db/iteration-writer.ts stamps makeSystemActor(
  SYSTEM_MISSION_RUNNER) instead of the old { kind:'system',
  source:'mission-runner' } shape. Phase 3 will switch this to an
  agent actor per mission.

Tests: 26 shared-ai + 21 webapp vitest + 35 mana-ai — all green.
svelte-check: 0 errors, 0 warnings.

No behavior change; purely a type + shape upgrade. Old sync_changes
rows parse via the normalizeActor compat shim at read time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:13:57 +02:00

232 lines
8.2 KiB
TypeScript

/**
* Actor attribution — the type carried on every DomainEvent, pending-
* change row, and sync_changes row in the Mana system.
*
* The three kinds (user/ai/system) are discriminators for UI + revert
* semantics; `principalId` is the identity under the kind. For a human
* writing, `principalId = userId`. For an AI agent, `principalId =
* agentId`. For system writes, it is one of the `SystemSource` sentinel
* strings (see below).
*
* `displayName` is cached at write time so historical records show
* "Cashflow Watcher" forever — even after the agent is renamed or
* deleted. This keeps timelines honest: old events describe what the
* user actually saw when they happened.
*
* Runtime helpers (runAs, ambient context) live in the webapp —
* browser single-threaded semantics + module-level mutable state,
* neither of which is appropriate for the server-side mana-ai service.
* This file exposes only the types, factories, and pure predicates so
* both runtimes can parse/build identical actor shapes.
*/
export type ActorKind = 'user' | 'ai' | 'system';
/**
* Fixed set of system-write sources. Each gets its own stable
* `principalId` so Workbench filters, revert logic, and forensics can
* distinguish projection writes from migration writes from the
* mission-runner's server-produced iterations.
*/
export const SYSTEM_PROJECTION = 'system:projection';
export const SYSTEM_RULE = 'system:rule';
export const SYSTEM_MIGRATION = 'system:migration';
export const SYSTEM_STREAM = 'system:stream';
export const SYSTEM_MISSION_RUNNER = 'system:mission-runner';
export type SystemSource =
| typeof SYSTEM_PROJECTION
| typeof SYSTEM_RULE
| typeof SYSTEM_MIGRATION
| typeof SYSTEM_STREAM
| typeof SYSTEM_MISSION_RUNNER;
/** Legacy sentinels for records that pre-date the identity-aware actor
* shape. Read-path normalization maps missing fields to these. */
export const LEGACY_USER_PRINCIPAL = 'legacy:user';
export const LEGACY_AI_PRINCIPAL = 'legacy:ai-default';
export const LEGACY_SYSTEM_PRINCIPAL = 'legacy:system';
export const LEGACY_DISPLAY_NAME = 'Unbekannt';
/** Fields common to every actor kind. */
export interface BaseActor {
/** UUID / sentinel identifying the specific principal. For kind='user'
* this is the userId; for 'ai' it's the agentId; for 'system' it's
* one of the `SystemSource` sentinels. */
readonly principalId: string;
/** Cached display name — frozen at write time so rename doesn't
* rewrite history. */
readonly displayName: string;
}
export interface UserActor extends BaseActor {
readonly kind: 'user';
}
export interface AiActor extends BaseActor {
readonly kind: 'ai';
/** Mission this write belongs to. */
readonly missionId: string;
/** Iteration within the mission (nth autonomous run). */
readonly iterationId: string;
/** Human-readable reason the AI took this action. */
readonly rationale: string;
}
export interface SystemActor extends BaseActor {
readonly kind: 'system';
}
/** Discriminated union over the three actor kinds. `Extract<Actor,
* { kind: 'ai' }>` narrows to `AiActor`. */
export type Actor = UserActor | AiActor | SystemActor;
// ─── Factories ───────────────────────────────────────────────
/**
* Build a user actor for the given userId. `displayName` defaults to
* "Du" since the webapp usually doesn't know the user's canonical
* display name at Dexie-hook time; callers that DO know (e.g. from
* the auth store) should pass the real value.
*/
export function makeUserActor(userId: string, displayName = 'Du'): UserActor {
return Object.freeze({ kind: 'user' as const, principalId: userId, displayName });
}
/**
* Build an agent actor. This is what the mana-ai runner and the
* webapp's tool executor stamp when an AI agent writes. The mission
* context is required — a bare "this came from some AI" without
* mission linkage would hide the agent's work from the revert path.
*/
export function makeAgentActor(args: {
agentId: string;
displayName: string;
missionId: string;
iterationId: string;
rationale: string;
}): AiActor {
return Object.freeze({
kind: 'ai' as const,
principalId: args.agentId,
displayName: args.displayName,
missionId: args.missionId,
iterationId: args.iterationId,
rationale: args.rationale,
});
}
/**
* Build a system actor for the given source. `displayName` defaults to
* a human-readable version of the source; callers rarely need to
* override this.
*/
export function makeSystemActor(source: SystemSource, displayName?: string): SystemActor {
return Object.freeze({
kind: 'system' as const,
principalId: source,
displayName: displayName ?? defaultSystemDisplayName(source),
});
}
function defaultSystemDisplayName(source: SystemSource): string {
switch (source) {
case SYSTEM_PROJECTION:
return 'Projektion';
case SYSTEM_RULE:
return 'Regel';
case SYSTEM_MIGRATION:
return 'Migration';
case SYSTEM_STREAM:
return 'Event-Stream';
case SYSTEM_MISSION_RUNNER:
return 'Mission-Runner';
}
}
// ─── Read-path compat ────────────────────────────────────────
/**
* Normalize a raw actor blob that came off the wire (sync_changes.actor,
* an old DomainEvent, a pre-identity Dexie record) into the current
* Actor shape. Missing `principalId` / `displayName` get filled with
* stable `legacy:*` sentinels so downstream code never crashes on
* historical data.
*
* Returns the argument unchanged when it's already a valid Actor — no
* allocation in the hot path.
*/
export function normalizeActor(raw: unknown): Actor {
if (!raw || typeof raw !== 'object') {
return makeUserActor(LEGACY_USER_PRINCIPAL, LEGACY_DISPLAY_NAME);
}
const a = raw as Partial<Actor> & { source?: string };
if (a.kind === 'user') {
if (typeof a.principalId === 'string' && typeof a.displayName === 'string') {
return a as Actor;
}
return makeUserActor(a.principalId ?? LEGACY_USER_PRINCIPAL, a.displayName ?? 'Du');
}
if (a.kind === 'ai') {
if (typeof a.principalId === 'string' && typeof a.displayName === 'string') {
return a as Actor;
}
return {
kind: 'ai',
principalId: a.principalId ?? LEGACY_AI_PRINCIPAL,
displayName: a.displayName ?? LEGACY_DISPLAY_NAME,
missionId: a.missionId ?? '',
iterationId: a.iterationId ?? '',
rationale: a.rationale ?? '',
};
}
if (a.kind === 'system') {
// Old shape carried `source: 'projection'|'rule'|'migration'|'mission-runner'`.
// New shape carries it inside principalId as 'system:<source>'.
if (typeof a.principalId === 'string' && typeof a.displayName === 'string') {
return a as Actor;
}
const legacySource =
typeof a.source === 'string' ? `system:${a.source}` : LEGACY_SYSTEM_PRINCIPAL;
return makeSystemActor(legacySource as SystemSource);
}
// Unknown kind → treat as legacy user.
return makeUserActor(LEGACY_USER_PRINCIPAL, LEGACY_DISPLAY_NAME);
}
// ─── Predicates ──────────────────────────────────────────────
export function isUserActor(actor: Actor | undefined): boolean {
return actor?.kind === 'user';
}
export function isAiActor(actor: Actor | undefined): boolean {
return actor?.kind === 'ai';
}
export function isSystemActor(actor: Actor | undefined): boolean {
return actor?.kind === 'system';
}
/** True when a write came from the server-side mission runner
* specifically (distinct from other system subsystems). */
export function isFromMissionRunner(actor: Actor | undefined): boolean {
return actor?.kind === 'system' && actor.principalId === SYSTEM_MISSION_RUNNER;
}
// ─── Legacy export ───────────────────────────────────────────
/**
* Placeholder user actor for tests and fallback sites. Uses a
* `legacy:user` principalId — call sites with a real userId should use
* `makeUserActor(userId, displayName)` instead.
*
* The webapp overrides this at login with the real user via an ambient-
* context setter; see `data/events/actor.ts` in the web app for the
* runtime-configurable default.
*/
export const USER_ACTOR: Actor = makeUserActor(LEGACY_USER_PRINCIPAL, 'Du');