mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
f7b5c9b3a4
commit
1771063df4
13 changed files with 570 additions and 115 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> }[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
133
packages/shared-ai/src/actor.test.ts
Normal file
133
packages/shared-ai/src/actor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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';
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue