diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts index fdfa89ec5..1ccc09ef0 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/server-iteration-staging.ts @@ -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(); @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/ai/policy.test.ts b/apps/mana/apps/web/src/lib/data/ai/policy.test.ts index 129837e85..618d73034 100644 --- a/apps/mana/apps/web/src/lib/data/ai/policy.test.ts +++ b/apps/mana/apps/web/src/lib/data/ai/policy.test.ts @@ -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', () => { diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts index 88a367eb8..715bed886 100644 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts +++ b/apps/mana/apps/web/src/lib/data/ai/proposals/queries.test.ts @@ -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 = { - 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(); diff --git a/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts b/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts index 575964c28..ef77f1b5e 100644 --- a/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts +++ b/apps/mana/apps/web/src/lib/data/ai/proposals/store.test.ts @@ -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 = { - 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 }[] = []; diff --git a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts index 9e0607035..5f20e6c27 100644 --- a/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts +++ b/apps/mana/apps/web/src/lib/data/ai/timeline/queries.test.ts @@ -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, + }), }, }; } diff --git a/apps/mana/apps/web/src/lib/data/events/actor.test.ts b/apps/mana/apps/web/src/lib/data/events/actor.test.ts index 73866b77b..2488bc89e 100644 --- a/apps/mana/apps/web/src/lib/data/events/actor.test.ts +++ b/apps/mana/apps/web/src/lib/data/events/actor.test.ts @@ -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); }); }); diff --git a/apps/mana/apps/web/src/lib/data/events/actor.ts b/apps/mana/apps/web/src/lib/data/events/actor.ts index 41c1688b8..f82d3c610 100644 --- a/apps/mana/apps/web/src/lib/data/events/actor.ts +++ b/apps/mana/apps/web/src/lib/data/events/actor.ts @@ -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(actor: Actor, fn: () => T): T { const previous = currentActor; @@ -69,10 +116,11 @@ export function runAs(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(actor: Actor, fn: () => Promise): Promise { const previous = currentActor; @@ -83,13 +131,3 @@ export async function runAsAsync(actor: Actor, fn: () => Promise): 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'; -} diff --git a/apps/mana/apps/web/src/lib/data/projections/streaks.ts b/apps/mana/apps/web/src/lib/data/projections/streaks.ts index 4bd719919..3e28304e8 100644 --- a/apps/mana/apps/web/src/lib/data/projections/streaks.ts +++ b/apps/mana/apps/web/src/lib/data/projections/streaks.ts @@ -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 ──────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/data/tools/executor.test.ts b/apps/mana/apps/web/src/lib/data/tools/executor.test.ts index 9c8b4831a..6cbb1f637 100644 --- a/apps/mana/apps/web/src/lib/data/tools/executor.test.ts +++ b/apps/mana/apps/web/src/lib/data/tools/executor.test.ts @@ -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 diff --git a/packages/shared-ai/src/actor.test.ts b/packages/shared-ai/src/actor.test.ts new file mode 100644 index 000000000..d732a9c40 --- /dev/null +++ b/packages/shared-ai/src/actor.test.ts @@ -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: 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'); + }); +}); diff --git a/packages/shared-ai/src/actor.ts b/packages/shared-ai/src/actor.ts index d21119098..6f613de1c 100644 --- a/packages/shared-ai/src/actor.ts +++ b/packages/shared-ai/src/actor.ts @@ -1,27 +1,208 @@ /** - * Actor attribution type — the discriminated union stamped on every - * event, record, and sync-change row in the Mana system. + * Actor attribution — the type carried on every DomainEvent, pending- + * change row, and sync_changes row in the Mana system. * - * Runtime helpers (runAs, runAsAsync, ambient context) stay in the webapp - * because they rely on browser single-threaded semantics and module-level - * mutable state. The *type* is shared so server-side consumers can parse - * incoming actors without re-declaring the union. + * 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 Actor = - | { 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 type ActorKind = 'user' | 'ai' | 'system'; -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` 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 & { 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:'. + 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'; @@ -30,3 +211,22 @@ export function isAiActor(actor: Actor | undefined): boolean { 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'); diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 5d76a3c2b..35ac92f31 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -7,8 +7,35 @@ * pure functions here must work in both environments. */ -export type { Actor } from './actor'; -export { USER_ACTOR, isAiActor, isSystemActor } from './actor'; +export type { + 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 { IterationPhase, diff --git a/services/mana-ai/src/db/iteration-writer.ts b/services/mana-ai/src/db/iteration-writer.ts index 3f28956b1..3cede8764 100644 --- a/services/mana-ai/src/db/iteration-writer.ts +++ b/services/mana-ai/src/db/iteration-writer.ts @@ -17,6 +17,7 @@ import type { Sql } from './connection'; import { withUser } from './connection'; +import { makeSystemActor, SYSTEM_MISSION_RUNNER } from '@mana/shared-ai'; import type { AiPlanOutput, MissionIteration, PlanStep } from '@mana/shared-ai'; export interface AppendIterationInput { @@ -36,9 +37,13 @@ export interface AppendIterationInput { } /** 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 { - return JSON.stringify({ kind: 'system', source: 'mission-runner' }); + return JSON.stringify(makeSystemActor(SYSTEM_MISSION_RUNNER)); } export async function appendServerIteration(sql: Sql, input: AppendIterationInput): Promise {