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 { createProposal } from '../proposals/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';
const processedIterations = new Set<string>();
@ -96,12 +96,16 @@ async function stageIteration(mission: Mission, iteration: MissionIteration): Pr
if (intent.kind !== 'toolCall') continue;
if (step.proposalId) continue; // already staged
const actor = {
kind: 'ai' as const,
// Phase 1: server-iteration writes under the legacy AI principal.
// 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,
iterationId: iteration.id,
rationale: step.summary || iteration.summary || mission.objective,
};
});
// createProposal runs through Dexie hooks under the AI actor — the
// 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 { registerTools } from '../tools/registry';
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 USER: Actor = { kind: 'user' };
const SYSTEM: Actor = { kind: 'system', source: 'projection' };
const AI: Actor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm',
iterationId: 'i',
rationale: 'r',
});
const USER: Actor = makeUserActor('u-1', 'Till');
const SYSTEM: Actor = makeSystemActor(SYSTEM_PROJECTION);
describe('resolvePolicy', () => {
it('always returns auto for user actors', () => {

View file

@ -11,7 +11,7 @@ import { db } from '../../database';
import { registerTools } from '../../tools/registry';
import { createProposal } from './store';
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
// something to discriminate against.
@ -36,12 +36,13 @@ registerTools([
},
]);
const AI: Extract<Actor, { kind: 'ai' }> = {
kind: 'ai',
const AI: AiActor = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-a',
iterationId: 'i-a',
rationale: 'r',
};
});
beforeEach(async () => {
await db.table(PROPOSALS_TABLE).clear();

View file

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

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { bucketByIteration } from './queries';
import type { DomainEvent } from '../../events/types';
import { USER_ACTOR } from '../../events/actor';
import { USER_ACTOR, makeAgentActor, LEGACY_AI_PRINCIPAL } from '../../events/actor';
function aiEvent(
missionId: string,
@ -20,7 +20,13 @@ function aiEvent(
collection: 'tasks',
recordId: crypto.randomUUID(),
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 { 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 { eventBus } from './event-bus';
import type { DomainEvent } from './types';
const AI_ACTOR = {
kind: 'ai',
const AI_ACTOR = makeAgentActor({
agentId: LEGACY_AI_PRINCIPAL,
displayName: 'Mana',
missionId: 'm-1',
iterationId: 'i-1',
rationale: 'test',
} as const;
});
const SYSTEM_ACTOR = { kind: 'system', source: 'projection' } as const;
const SYSTEM_ACTOR = makeSystemActor(SYSTEM_PROJECTION);
describe('actor context', () => {
it('defaults to the user actor', () => {
@ -42,20 +54,29 @@ describe('actor context', () => {
});
it('supports nesting', () => {
runAs({ ...AI_ACTOR, missionId: 'outer' }, () => {
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer');
runAs({ ...AI_ACTOR, missionId: 'inner' }, () => {
expect((getCurrentActor() as { missionId: string }).missionId).toBe('inner');
});
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer');
});
runAs(
makeAgentActor({ ...AI_ACTOR, agentId: AI_ACTOR.principalId, missionId: 'outer' }),
() => {
expect((getCurrentActor() as { missionId: string }).missionId).toBe('outer');
runAs(
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 () => {
await runAsAsync({ ...AI_ACTOR, missionId: 'async' }, async () => {
await Promise.resolve();
expect((getCurrentActor() as { missionId: string }).missionId).toBe('async');
});
await runAsAsync(
makeAgentActor({ ...AI_ACTOR, agentId: AI_ACTOR.principalId, missionId: 'async' }),
async () => {
await Promise.resolve();
expect((getCurrentActor() as { missionId: string }).missionId).toBe('async');
}
);
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
* the UI can distinguish user-initiated work from AI-initiated work, render
* ghost state for proposals, attribute field-level edits, and let the user
* revert a whole mission.
* 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.
*
* Three actor kinds:
* - `user` the human at the keyboard
* - `ai` autonomous AI work, carrying mission/iteration metadata so the
* Workbench can group, review, and revert per-mission
* - `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.
* Default user identity: `USER_ACTOR` (re-exported from shared-ai)
* starts with a `legacy:user` placeholder. The layout's login effect
* must call `bindDefaultUser(userId, displayName)` so the real
* identity gets stamped onto subsequent writes.
*/
export type Actor =
| { 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';
};
import { type Actor, makeUserActor, USER_ACTOR as LEGACY_USER_ACTOR } from '@mana/shared-ai';
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. */
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
* previous actor on exit, even if `fn` throws. Supports nesting.
* Run `fn` with the given actor pinned to the current context. Restores
* the previous actor on exit, even if `fn` throws. Supports nesting.
*
* Use this at the three defined boundaries only:
* 1. Tool executor (AI-initiated tool calls)
* 2. Mission runner (background AI loop)
* 3. Projection / rule dispatcher (system-initiated cascades)
* Past those boundaries every write primitive freezes the actor onto data
* do not rely on ambient context across `setTimeout` / `queueMicrotask` hops.
* Past those boundaries every write primitive freezes the actor onto
* data do not rely on ambient context across `setTimeout` /
* `queueMicrotask` hops.
*/
export function runAs<T>(actor: Actor, fn: () => T): T {
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
* the same Promise chain, but NOT across `setTimeout` or un-awaited work.
* That is fine only because every primitive (emitDomainEvent, Dexie hooks)
* captures the actor synchronously at the write moment.
* Async variant of {@link runAs}. The actor stays pinned across awaits
* within the same Promise chain, but NOT across `setTimeout` or
* un-awaited work. That is fine only because every primitive
* (emitDomainEvent, Dexie hooks) captures the actor synchronously at
* the write moment.
*/
export async function runAsAsync<T>(actor: Actor, fn: () => Promise<T>): Promise<T> {
const previous = currentActor;
@ -83,13 +131,3 @@ export async function runAsAsync<T>(actor: Actor, fn: () => Promise<T>): Promise
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 { useLiveQueryWithDefault } from '@mana/local-store/svelte';
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 { StreakInfo } from './types';
const PROJECTION_ACTOR = { kind: 'system', source: 'projection' } as const;
const PROJECTION_ACTOR = makeSystemActor(SYSTEM_PROJECTION, 'Streak-Tracker');
// ── Persistent State ────────────────────────────────

View file

@ -13,10 +13,16 @@ import { setAiPolicy } from '../ai/policy';
import { listProposals, approveProposal } from '../ai/proposals/store';
import { PROPOSALS_TABLE } from '../ai/proposals/types';
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';
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
// Instead, we just register test tools and rely on dedup