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>
This commit is contained in:
Till JS 2026-04-15 20:13:57 +02:00
parent f7b5c9b3a4
commit 1771063df4
13 changed files with 570 additions and 115 deletions

View file

@ -21,7 +21,7 @@ import { db } from '../../database';
import { MISSIONS_TABLE } from './types'; import { MISSIONS_TABLE } from './types';
import { createProposal } from '../proposals/store'; import { createProposal } from '../proposals/store';
import { getMission } from './store'; import { getMission } from './store';
import { runAsAsync } from '../../events/actor'; import { runAsAsync, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor';
import type { Mission, MissionIteration, PlanStep } from './types'; import type { Mission, MissionIteration, PlanStep } from './types';
const processedIterations = new Set<string>(); const processedIterations = new Set<string>();
@ -96,12 +96,16 @@ async function stageIteration(mission: Mission, iteration: MissionIteration): Pr
if (intent.kind !== 'toolCall') continue; if (intent.kind !== 'toolCall') continue;
if (step.proposalId) continue; // already staged if (step.proposalId) continue; // already staged
const actor = { // Phase 1: server-iteration writes under the legacy AI principal.
kind: 'ai' as const, // Phase 2 will swap this for the mission's owning-agent identity
// once agents are wired into the data layer.
const actor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: mission.id, missionId: mission.id,
iterationId: iteration.id, iterationId: iteration.id,
rationale: step.summary || iteration.summary || mission.objective, rationale: step.summary || iteration.summary || mission.objective,
}; });
// createProposal runs through Dexie hooks under the AI actor — the // createProposal runs through Dexie hooks under the AI actor — the
// row lands in `pendingProposals` and the AiProposalInbox renders // row lands in `pendingProposals` and the AiProposalInbox renders

View file

@ -2,11 +2,24 @@ import { describe, it, expect } from 'vitest';
import { resolvePolicy, setAiPolicy, DEFAULT_AI_POLICY } from './policy'; import { resolvePolicy, setAiPolicy, DEFAULT_AI_POLICY } from './policy';
import { registerTools } from '../tools/registry'; import { registerTools } from '../tools/registry';
import { AI_PROPOSABLE_TOOL_NAMES } from '@mana/shared-ai'; import { AI_PROPOSABLE_TOOL_NAMES } from '@mana/shared-ai';
import type { Actor } from '../events/actor'; import {
makeUserActor,
makeAgentActor,
makeSystemActor,
LEGACY_AI_PRINCIPAL,
SYSTEM_PROJECTION,
type Actor,
} from '../events/actor';
const AI: Actor = { kind: 'ai', missionId: 'm', iterationId: 'i', rationale: 'r' }; const AI: Actor = makeAgentActor({
const USER: Actor = { kind: 'user' }; agentId: LEGACY_AI_PRINCIPAL,
const SYSTEM: Actor = { kind: 'system', source: 'projection' }; displayName: 'Mana',
missionId: 'm',
iterationId: 'i',
rationale: 'r',
});
const USER: Actor = makeUserActor('u-1', 'Till');
const SYSTEM: Actor = makeSystemActor(SYSTEM_PROJECTION);
describe('resolvePolicy', () => { describe('resolvePolicy', () => {
it('always returns auto for user actors', () => { it('always returns auto for user actors', () => {

View file

@ -11,7 +11,7 @@ import { db } from '../../database';
import { registerTools } from '../../tools/registry'; import { registerTools } from '../../tools/registry';
import { createProposal } from './store'; import { createProposal } from './store';
import { PROPOSALS_TABLE } from './types'; import { PROPOSALS_TABLE } from './types';
import type { Actor } from '../../events/actor'; import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
// Register two tools in distinct modules so the `module` filter has // Register two tools in distinct modules so the `module` filter has
// something to discriminate against. // something to discriminate against.
@ -36,12 +36,13 @@ registerTools([
}, },
]); ]);
const AI: Extract<Actor, { kind: 'ai' }> = { const AI: AiActor = makeAgentActor({
kind: 'ai', agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-a', missionId: 'm-a',
iterationId: 'i-a', iterationId: 'i-a',
rationale: 'r', rationale: 'r',
}; });
beforeEach(async () => { beforeEach(async () => {
await db.table(PROPOSALS_TABLE).clear(); await db.table(PROPOSALS_TABLE).clear();

View file

@ -18,14 +18,15 @@ import {
getProposal, getProposal,
} from './store'; } from './store';
import { PROPOSALS_TABLE } from './types'; import { PROPOSALS_TABLE } from './types';
import type { Actor } from '../../events/actor'; import { makeAgentActor, LEGACY_AI_PRINCIPAL, type AiActor } from '../../events/actor';
const AI: Extract<Actor, { kind: 'ai' }> = { const AI: AiActor = makeAgentActor({
kind: 'ai', agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'mission-1', missionId: 'mission-1',
iterationId: 'iter-1', iterationId: 'iter-1',
rationale: 'test run', rationale: 'test run',
}; });
let executed: { name: string; params: Record<string, unknown> }[] = []; let executed: { name: string; params: Record<string, unknown> }[] = [];

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { bucketByIteration } from './queries'; import { bucketByIteration } from './queries';
import type { DomainEvent } from '../../events/types'; import type { DomainEvent } from '../../events/types';
import { USER_ACTOR } from '../../events/actor'; import { USER_ACTOR, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor';
function aiEvent( function aiEvent(
missionId: string, missionId: string,
@ -20,7 +20,13 @@ function aiEvent(
collection: 'tasks', collection: 'tasks',
recordId: crypto.randomUUID(), recordId: crypto.randomUUID(),
userId: 'u1', userId: 'u1',
actor: { kind: 'ai', missionId, iterationId, rationale }, actor: makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId,
iterationId,
rationale,
}),
}, },
}; };
} }

View file

@ -1,17 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { runAs, runAsAsync, getCurrentActor, USER_ACTOR, isAiActor, isSystemActor } from './actor'; import {
runAs,
runAsAsync,
getCurrentActor,
USER_ACTOR,
isAiActor,
isSystemActor,
makeAgentActor,
makeSystemActor,
SYSTEM_PROJECTION,
LEGACY_AI_PRINCIPAL,
} from './actor';
import { emitDomainEvent } from './emit'; import { emitDomainEvent } from './emit';
import { eventBus } from './event-bus'; import { eventBus } from './event-bus';
import type { DomainEvent } from './types'; import type { DomainEvent } from './types';
const AI_ACTOR = { const AI_ACTOR = makeAgentActor({
kind: 'ai', agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-1', missionId: 'm-1',
iterationId: 'i-1', iterationId: 'i-1',
rationale: 'test', rationale: 'test',
} as const; });
const SYSTEM_ACTOR = { kind: 'system', source: 'projection' } as const; const SYSTEM_ACTOR = makeSystemActor(SYSTEM_PROJECTION);
describe('actor context', () => { describe('actor context', () => {
it('defaults to the user actor', () => { it('defaults to the user actor', () => {
@ -42,20 +54,29 @@ describe('actor context', () => {
}); });
it('supports nesting', () => { it('supports nesting', () => {
runAs({ ...AI_ACTOR, missionId: 'outer' }, () => { runAs(
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer'); makeAgentActor({ ...AI_ACTOR, agentId: AI_ACTOR.principalId, missionId: 'outer' }),
runAs({ ...AI_ACTOR, missionId: 'inner' }, () => { () => {
expect((getCurrentActor() as { missionId: string }).missionId).toBe('inner'); expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer');
}); runAs(
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer'); makeAgentActor({ ...AI_ACTOR, agentId: AI_ACTOR.principalId, missionId: 'inner' }),
}); () => {
expect((getCurrentActor() as { missionId: string }).missionId).toBe('inner');
}
);
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer');
}
);
}); });
it('preserves the actor across awaits inside runAsAsync', async () => { it('preserves the actor across awaits inside runAsAsync', async () => {
await runAsAsync({ ...AI_ACTOR, missionId: 'async' }, async () => { await runAsAsync(
await Promise.resolve(); makeAgentActor({ ...AI_ACTOR, agentId: AI_ACTOR.principalId, missionId: 'async' }),
expect((getCurrentActor() as { missionId: string }).missionId).toBe('async'); async () => {
}); await Promise.resolve();
expect((getCurrentActor() as { missionId: string }).missionId).toBe('async');
}
);
expect(getCurrentActor()).toEqual(USER_ACTOR); expect(getCurrentActor()).toEqual(USER_ACTOR);
}); });
}); });

View file

@ -1,46 +1,92 @@
/** /**
* Actor attribution who triggered a write. * Actor attribution runtime side (ambient context + configurable user
* default). The TYPE + factories + predicates live in `@mana/shared-ai`
* so the server-side mana-ai runner parses identical shapes.
* *
* Every DomainEvent, pending-change row, and synced record carries an Actor so * Threading model: a module-level "current actor" acts like an
* the UI can distinguish user-initiated work from AI-initiated work, render * AsyncLocalStorage fiber. The browser is single-threaded and the
* ghost state for proposals, attribute field-level edits, and let the user * Dexie write path is synchronous, so a mutable slot wrapped by
* revert a whole mission. * `runAs(actor, fn)` is enough at the boundaries (UI handlers,
* executor, runners). At the primitive sites (Dexie hooks,
* `emitDomainEvent`) the actor is **captured synchronously** and
* frozen onto the data ambient context is never the source of truth
* past that point.
* *
* Three actor kinds: * Default user identity: `USER_ACTOR` (re-exported from shared-ai)
* - `user` the human at the keyboard * starts with a `legacy:user` placeholder. The layout's login effect
* - `ai` autonomous AI work, carrying mission/iteration metadata so the * must call `bindDefaultUser(userId, displayName)` so the real
* Workbench can group, review, and revert per-mission * identity gets stamped onto subsequent writes.
* - `system` derived writes (projections, rule engines, data migrations)
* that are neither user nor AI
*
* Threading model: a module-level "current actor" acts like an AsyncLocalStorage
* fiber. The browser is single-threaded and the Dexie write path is synchronous,
* so a mutable slot wrapped by `runAs(actor, fn)` is enough at the boundaries
* (UI handlers, executor, runners). At the primitive sites (Dexie hooks,
* `emitDomainEvent`) the actor is **captured synchronously** and frozen onto
* the data ambient context is never the source of truth past that point.
*/ */
export type Actor = import { type Actor, makeUserActor, USER_ACTOR as LEGACY_USER_ACTOR } from '@mana/shared-ai';
| { readonly kind: 'user' }
| {
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;
}
| {
readonly kind: 'system';
/** Subsystem responsible for this derived write. */
readonly source: 'projection' | 'rule' | 'migration' | 'mission-runner';
};
export const USER_ACTOR: Actor = Object.freeze({ kind: 'user' }); export type {
Actor,
ActorKind,
BaseActor,
UserActor,
AiActor,
SystemActor,
SystemSource,
} from '@mana/shared-ai';
export {
SYSTEM_PROJECTION,
SYSTEM_RULE,
SYSTEM_MIGRATION,
SYSTEM_STREAM,
SYSTEM_MISSION_RUNNER,
LEGACY_USER_PRINCIPAL,
LEGACY_AI_PRINCIPAL,
LEGACY_SYSTEM_PRINCIPAL,
LEGACY_DISPLAY_NAME,
makeUserActor,
makeAgentActor,
makeSystemActor,
normalizeActor,
isUserActor,
isAiActor,
isSystemActor,
isFromMissionRunner,
} from '@mana/shared-ai';
let currentActor: Actor = USER_ACTOR; /**
* The "logged-in user" actor, used as the default ambient context.
* Starts as the shared-ai legacy placeholder so early module-init
* writes (before login has finished) still produce a valid actor.
* Replaced by `bindDefaultUser` once the auth store has resolved.
*/
let defaultUserActor: Actor = LEGACY_USER_ACTOR;
/**
* Bind the real user identity to the default ambient actor. Called
* once from the app-shell `onMount` after the auth store resolves.
* Safe to call multiple times (e.g. on user switch); the most recent
* call wins.
*/
export function bindDefaultUser(userId: string, displayName: string): void {
const next = makeUserActor(userId, displayName);
if (currentActor === defaultUserActor) {
currentActor = next;
}
defaultUserActor = next;
if (typeof window !== 'undefined') {
console.info(`[actor] bindDefaultUser: ${displayName} (${userId.slice(0, 8)}…)`);
}
}
/** Re-export the legacy constant for call sites that haven't been
* migrated yet. Prefer `getCurrentActor()` or `defaultUser()` for new
* code; this stays around because a grep-rewrite of every test fixture
* would add no value. */
export const USER_ACTOR = LEGACY_USER_ACTOR;
/** The currently bound default user actor. Use when you want to attribute
* a write to "the logged-in user" without reading ambient context. */
export function defaultUser(): Actor {
return defaultUserActor;
}
let currentActor: Actor = defaultUserActor;
/** Returns the actor attributed to the currently executing write. */ /** Returns the actor attributed to the currently executing write. */
export function getCurrentActor(): Actor { export function getCurrentActor(): Actor {
@ -48,15 +94,16 @@ export function getCurrentActor(): Actor {
} }
/** /**
* Run `fn` with the given actor pinned to the current context. Restores the * Run `fn` with the given actor pinned to the current context. Restores
* previous actor on exit, even if `fn` throws. Supports nesting. * the previous actor on exit, even if `fn` throws. Supports nesting.
* *
* Use this at the three defined boundaries only: * Use this at the three defined boundaries only:
* 1. Tool executor (AI-initiated tool calls) * 1. Tool executor (AI-initiated tool calls)
* 2. Mission runner (background AI loop) * 2. Mission runner (background AI loop)
* 3. Projection / rule dispatcher (system-initiated cascades) * 3. Projection / rule dispatcher (system-initiated cascades)
* Past those boundaries every write primitive freezes the actor onto data * Past those boundaries every write primitive freezes the actor onto
* do not rely on ambient context across `setTimeout` / `queueMicrotask` hops. * data do not rely on ambient context across `setTimeout` /
* `queueMicrotask` hops.
*/ */
export function runAs<T>(actor: Actor, fn: () => T): T { export function runAs<T>(actor: Actor, fn: () => T): T {
const previous = currentActor; const previous = currentActor;
@ -69,10 +116,11 @@ export function runAs<T>(actor: Actor, fn: () => T): T {
} }
/** /**
* Async variant of {@link runAs}. The actor stays pinned across awaits within * Async variant of {@link runAs}. The actor stays pinned across awaits
* the same Promise chain, but NOT across `setTimeout` or un-awaited work. * within the same Promise chain, but NOT across `setTimeout` or
* That is fine only because every primitive (emitDomainEvent, Dexie hooks) * un-awaited work. That is fine only because every primitive
* captures the actor synchronously at the write moment. * (emitDomainEvent, Dexie hooks) captures the actor synchronously at
* the write moment.
*/ */
export async function runAsAsync<T>(actor: Actor, fn: () => Promise<T>): Promise<T> { export async function runAsAsync<T>(actor: Actor, fn: () => Promise<T>): Promise<T> {
const previous = currentActor; const previous = currentActor;
@ -83,13 +131,3 @@ export async function runAsAsync<T>(actor: Actor, fn: () => Promise<T>): Promise
currentActor = previous; currentActor = previous;
} }
} }
/** True when an AI agent wrote this record/event/field. */
export function isAiActor(actor: Actor | undefined): boolean {
return actor?.kind === 'ai';
}
/** True when a derived subsystem (projection / rule / migration) wrote it. */
export function isSystemActor(actor: Actor | undefined): boolean {
return actor?.kind === 'system';
}

View file

@ -17,11 +17,11 @@
import { db } from '../database'; import { db } from '../database';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { eventBus } from '../events/event-bus'; import { eventBus } from '../events/event-bus';
import { runAsAsync } from '../events/actor'; import { runAsAsync, makeSystemActor, SYSTEM_PROJECTION } from '../events/actor';
import type { DomainEvent } from '../events/types'; import type { DomainEvent } from '../events/types';
import type { StreakInfo } from './types'; import type { StreakInfo } from './types';
const PROJECTION_ACTOR = { kind: 'system', source: 'projection' } as const; const PROJECTION_ACTOR = makeSystemActor(SYSTEM_PROJECTION, 'Streak-Tracker');
// ── Persistent State ──────────────────────────────── // ── Persistent State ────────────────────────────────

View file

@ -13,10 +13,16 @@ import { setAiPolicy } from '../ai/policy';
import { listProposals, approveProposal } from '../ai/proposals/store'; import { listProposals, approveProposal } from '../ai/proposals/store';
import { PROPOSALS_TABLE } from '../ai/proposals/types'; import { PROPOSALS_TABLE } from '../ai/proposals/types';
import { db } from '../database'; import { db } from '../database';
import type { Actor } from '../events/actor'; import { makeAgentActor, LEGACY_AI_PRINCIPAL, type Actor } from '../events/actor';
import type { ModuleTool } from './types'; import type { ModuleTool } from './types';
const AI: Actor = { kind: 'ai', missionId: 'm-1', iterationId: 'it-1', rationale: 'because' }; const AI: Actor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-1',
iterationId: 'it-1',
rationale: 'because',
});
// Reset registry between tests by reloading — registry uses module-level array // Reset registry between tests by reloading — registry uses module-level array
// Instead, we just register test tools and rely on dedup // Instead, we just register test tools and rely on dedup

View file

@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import {
makeUserActor,
makeAgentActor,
makeSystemActor,
normalizeActor,
isUserActor,
isAiActor,
isSystemActor,
isFromMissionRunner,
SYSTEM_PROJECTION,
SYSTEM_MISSION_RUNNER,
LEGACY_USER_PRINCIPAL,
LEGACY_DISPLAY_NAME,
USER_ACTOR,
} from './actor';
describe('factories', () => {
it('makeUserActor carries userId + displayName', () => {
const a = makeUserActor('user-1', 'Till');
expect(a.kind).toBe('user');
expect(a.principalId).toBe('user-1');
expect(a.displayName).toBe('Till');
});
it('makeUserActor default displayName is "Du"', () => {
expect(makeUserActor('u').displayName).toBe('Du');
});
it('makeAgentActor carries mission context', () => {
const a = makeAgentActor({
agentId: 'agent-1',
displayName: 'Cashflow Watcher',
missionId: 'm1',
iterationId: 'it1',
rationale: 'weekly review',
});
expect(a.kind).toBe('ai');
expect(a.principalId).toBe('agent-1');
expect(a.displayName).toBe('Cashflow Watcher');
expect(a.missionId).toBe('m1');
expect(a.iterationId).toBe('it1');
expect(a.rationale).toBe('weekly review');
});
it('makeSystemActor picks a default displayName per source', () => {
expect(makeSystemActor(SYSTEM_PROJECTION).displayName).toBe('Projektion');
expect(makeSystemActor(SYSTEM_MISSION_RUNNER).displayName).toBe('Mission-Runner');
});
it('makeSystemActor accepts a displayName override', () => {
expect(makeSystemActor(SYSTEM_PROJECTION, 'Streak-Tracker').displayName).toBe('Streak-Tracker');
});
});
describe('predicates', () => {
const u = makeUserActor('u');
const a = makeAgentActor({
agentId: 'a1',
displayName: 'x',
missionId: 'm',
iterationId: 'i',
rationale: 'r',
});
const s = makeSystemActor(SYSTEM_PROJECTION);
const mr = makeSystemActor(SYSTEM_MISSION_RUNNER);
it('identifies kinds', () => {
expect(isUserActor(u)).toBe(true);
expect(isUserActor(a)).toBe(false);
expect(isAiActor(a)).toBe(true);
expect(isSystemActor(s)).toBe(true);
});
it('isFromMissionRunner only fires for mission-runner principalId', () => {
expect(isFromMissionRunner(mr)).toBe(true);
expect(isFromMissionRunner(s)).toBe(false);
});
it('predicates handle undefined', () => {
expect(isUserActor(undefined)).toBe(false);
expect(isAiActor(undefined)).toBe(false);
expect(isSystemActor(undefined)).toBe(false);
});
});
describe('normalizeActor', () => {
it('passes modern actors through unchanged', () => {
const a = makeUserActor('u', 'Till');
expect(normalizeActor(a)).toBe(a);
});
it('fills in principalId+displayName for legacy {kind:"user"} events', () => {
const legacy = { kind: 'user' };
const n = normalizeActor(legacy);
expect(n.kind).toBe('user');
expect(n.principalId).toBe(LEGACY_USER_PRINCIPAL);
expect(n.displayName).toBe('Du');
});
it('fills in AI fields for legacy {kind:"ai", missionId, ...} without identity', () => {
const legacy = { kind: 'ai', missionId: 'm1', iterationId: 'i1', rationale: 'r' };
const n = normalizeActor(legacy);
expect(n.kind).toBe('ai');
expect(n.principalId).toBe('legacy:ai-default');
expect(n.displayName).toBe(LEGACY_DISPLAY_NAME);
if (n.kind !== 'ai') throw new Error('narrowed to ai');
expect(n.missionId).toBe('m1');
});
it('maps old system.source field into new system:<source> principalId', () => {
const legacy = { kind: 'system', source: 'projection' };
const n = normalizeActor(legacy);
expect(n.kind).toBe('system');
expect(n.principalId).toBe('system:projection');
expect(n.displayName).toBe('Projektion');
});
it('handles completely garbage input', () => {
expect(normalizeActor(null).kind).toBe('user');
expect(normalizeActor('hello').kind).toBe('user');
expect(normalizeActor(undefined).principalId).toBe(LEGACY_USER_PRINCIPAL);
expect(normalizeActor({ kind: 'alien' }).kind).toBe('user');
});
});
describe('USER_ACTOR legacy export', () => {
it('uses legacy principal + "Du" display', () => {
expect(USER_ACTOR.kind).toBe('user');
expect(USER_ACTOR.principalId).toBe(LEGACY_USER_PRINCIPAL);
expect(USER_ACTOR.displayName).toBe('Du');
});
});

View file

@ -1,27 +1,208 @@
/** /**
* Actor attribution type the discriminated union stamped on every * Actor attribution the type carried on every DomainEvent, pending-
* event, record, and sync-change row in the Mana system. * change row, and sync_changes row in the Mana system.
* *
* Runtime helpers (runAs, runAsAsync, ambient context) stay in the webapp * The three kinds (user/ai/system) are discriminators for UI + revert
* because they rely on browser single-threaded semantics and module-level * semantics; `principalId` is the identity under the kind. For a human
* mutable state. The *type* is shared so server-side consumers can parse * writing, `principalId = userId`. For an AI agent, `principalId =
* incoming actors without re-declaring the union. * 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 Actor = export type ActorKind = 'user' | 'ai' | 'system';
| { readonly kind: 'user' }
| {
readonly kind: 'ai';
readonly missionId: string;
readonly iterationId: string;
readonly rationale: string;
}
| {
readonly kind: 'system';
readonly source: 'projection' | 'rule' | 'migration' | 'mission-runner';
};
export const USER_ACTOR: Actor = Object.freeze({ kind: 'user' }); /**
* 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 { export function isAiActor(actor: Actor | undefined): boolean {
return actor?.kind === 'ai'; return actor?.kind === 'ai';
@ -30,3 +211,22 @@ export function isAiActor(actor: Actor | undefined): boolean {
export function isSystemActor(actor: Actor | undefined): boolean { export function isSystemActor(actor: Actor | undefined): boolean {
return actor?.kind === 'system'; 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');

View file

@ -7,8 +7,35 @@
* pure functions here must work in both environments. * pure functions here must work in both environments.
*/ */
export type { Actor } from './actor'; export type {
export { USER_ACTOR, isAiActor, isSystemActor } from './actor'; Actor,
ActorKind,
BaseActor,
UserActor,
AiActor,
SystemActor,
SystemSource,
} from './actor';
export {
SYSTEM_PROJECTION,
SYSTEM_RULE,
SYSTEM_MIGRATION,
SYSTEM_STREAM,
SYSTEM_MISSION_RUNNER,
LEGACY_USER_PRINCIPAL,
LEGACY_AI_PRINCIPAL,
LEGACY_SYSTEM_PRINCIPAL,
LEGACY_DISPLAY_NAME,
USER_ACTOR,
makeUserActor,
makeAgentActor,
makeSystemActor,
normalizeActor,
isUserActor,
isAiActor,
isSystemActor,
isFromMissionRunner,
} from './actor';
export type { export type {
IterationPhase, IterationPhase,

View file

@ -17,6 +17,7 @@
import type { Sql } from './connection'; import type { Sql } from './connection';
import { withUser } from './connection'; import { withUser } from './connection';
import { makeSystemActor, SYSTEM_MISSION_RUNNER } from '@mana/shared-ai';
import type { AiPlanOutput, MissionIteration, PlanStep } from '@mana/shared-ai'; import type { AiPlanOutput, MissionIteration, PlanStep } from '@mana/shared-ai';
export interface AppendIterationInput { export interface AppendIterationInput {
@ -36,9 +37,13 @@ export interface AppendIterationInput {
} }
/** Actor blob stamped on the sync_changes row. JSON string already /** Actor blob stamped on the sync_changes row. JSON string already
* we pass it as `json.RawMessage` equivalent through pgx. */ * we pass it as `json.RawMessage` equivalent through pgx. Uses the
* identity-aware Actor shape from @mana/shared-ai so the webapp's
* timeline can group + filter server-produced iterations alongside
* agent/user writes. Phase 2 will switch this to a per-agent actor
* when the mission carries `agentId`. */
function systemActorJson(): string { function systemActorJson(): string {
return JSON.stringify({ kind: 'system', source: 'mission-runner' }); return JSON.stringify(makeSystemActor(SYSTEM_MISSION_RUNNER));
} }
export async function appendServerIteration(sql: Sql, input: AppendIterationInput): Promise<void> { export async function appendServerIteration(sql: Sql, input: AppendIterationInput): Promise<void> {