diff --git a/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts index f2937a5f8..8d8d6ccf0 100644 --- a/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts +++ b/apps/mana/apps/web/src/lib/data/ai/agents/bootstrap.ts @@ -2,44 +2,118 @@ * Default-agent bootstrap + legacy-mission backfill. * * Runs once at app-shell init (via `startMissionTick` in setup.ts or a - * layout effect). Idempotent — safe to call on every mount, cross-tab - * races resolve via the store's `getOrCreateAgent` upsert. + * layout effect) and once per Space activation (via the + * onActiveSpaceChanged hook in 2d.4). Idempotent — safe to call on + * every mount, cross-tab races resolve via the store's + * `getOrCreateAgent` upsert. * - * Flow: - * 1. Ensure a "Mana" agent exists with `id = DEFAULT_AGENT_ID`. This - * id mirrors `LEGACY_AI_PRINCIPAL` so historic events and fresh - * actors map to the same principal. - * 2. One-off migration: any mission without `agentId` gets the - * default agent's id. This is a Dexie write so the change flows - * through sync like any user edit. + * Per-Space since Phase 2d.3 of the space-scoped data model rollout: + * every Space gets its own default agent. Personal-Space keeps the + * legacy `DEFAULT_AGENT_ID` + "Mana" name to preserve historic Actor + * attribution (`__lastActor.principalId` on pre-migration records + * points at `LEGACY_AI_PRINCIPAL` which equals `DEFAULT_AGENT_ID`). + * Shared/Brand/Family/Team/Club/Practice Spaces get a deterministic + * per-Space id (`default:`) and a SpaceType-aware display + * name so users don't see three "Mana" in their agent picker. * - * The migration step is gated by a localStorage sentinel so it runs - * only once per device per rollout. A user could manually re-trigger - * it via `resetBackfillSentinel()`, but there is no UI for that — it - * exists purely for test + recovery plumbing. + * The mission-backfill step is gated by a localStorage sentinel so it + * runs only once per device per rollout. Re-trigger via + * `resetBackfillSentinel()` (no UI — test/recovery plumbing only). */ +import type { SpaceType } from '@mana/shared-types'; import { db } from '../../database'; +import { encryptRecord } from '../../crypto'; import type { Mission } from '../missions/types'; import { MISSIONS_TABLE } from '../missions/types'; -import { getOrCreateAgent } from './store'; +import { getActiveSpace } from '../../scope/active-space.svelte'; +import { DEFAULT_AI_POLICY } from '../policy'; +import { getAgent } from './store'; import type { Agent } from './types'; -import { DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types'; +import { AGENTS_TABLE, DEFAULT_AGENT_ID, DEFAULT_AGENT_NAME } from './types'; + +/** + * Display name for the default agent bootstrapped in each Space type. + * Personal keeps "Mana" to match legacy records; everything else picks + * a name that reads naturally for that kind of Space. + */ +const DEFAULT_AGENT_NAMES: Record = { + personal: DEFAULT_AGENT_NAME, // "Mana" + family: 'Familien-Helfer', + team: 'Team-Assistent', + brand: 'Brand-Assistent', + club: 'Verein-Helfer', + practice: 'Praxis-Assistent', +}; + +const DEFAULT_AGENT_ROLES: Record = { + personal: 'Standard-Assistent für alle Missionen', + family: 'Hilft der Familie bei gemeinsamen Aufgaben, Einkäufen und Planung', + team: 'Koordiniert Team-Workflows, Sprints und gemeinsame Deliverables', + brand: 'Unterstützt bei Kampagnen, Content und Kundenkommunikation', + club: 'Hilft bei Vereinsorganisation, Veranstaltungen und Mitgliederbetreuung', + practice: 'Unterstützt Praxisabläufe, Termine und Patientenkommunikation', +}; + +/** + * Stable id for the default agent in a Space. Personal-Space maps to + * the legacy DEFAULT_AGENT_ID so historic Actor attribution keeps + * rendering; every other Space type uses `default:` which is + * deterministic + collision-free across Spaces. + */ +function defaultAgentIdForSpace(spaceId: string, spaceType: SpaceType): string { + return spaceType === 'personal' ? DEFAULT_AGENT_ID : `default:${spaceId}`; +} const BACKFILL_SENTINEL_KEY = 'mana:agents:default-backfill:v1'; /** - * Create the default agent if missing. Returns the materialized agent - * record. Safe under concurrent tabs — the store's `getOrCreateAgent` - * dedupes on the stable id. + * Create the default agent for the active Space if missing. Returns + * the materialized agent. If no Space is active yet, falls back to the + * legacy Personal-Space default so first-boot before space-load still + * has an agent to attribute writes to. + * + * We bypass the regular createAgent path (which enforces global + * name-uniqueness) because the same display name — e.g. + * "Familien-Helfer" — is expected to exist across multiple Spaces of + * the same type. Deduplication is by the deterministic per-Space id + * instead; cross-tab races land on the same id and lose cleanly to + * Dexie's add-or-skip. */ export async function ensureDefaultAgent(): Promise { - return getOrCreateAgent({ - id: DEFAULT_AGENT_ID, - name: DEFAULT_AGENT_NAME, + const space = getActiveSpace(); + const spaceType: SpaceType = space?.type ?? 'personal'; + const spaceId = space?.id; + + const id = spaceId ? defaultAgentIdForSpace(spaceId, spaceType) : DEFAULT_AGENT_ID; + + const existing = await getAgent(id); + if (existing) return existing; + + const now = new Date().toISOString(); + const agent: Agent = { + id, + createdAt: now, + updatedAt: now, + name: DEFAULT_AGENT_NAMES[spaceType], + role: DEFAULT_AGENT_ROLES[spaceType], avatar: '🤖', - role: 'Standard-Assistent für alle Missionen', - }); + policy: JSON.parse(JSON.stringify(DEFAULT_AI_POLICY)), + maxConcurrentMissions: 1, + state: 'active', + }; + const toWrite: Agent = { ...agent }; + await encryptRecord(AGENTS_TABLE, toWrite); + try { + await db.table(AGENTS_TABLE).add(toWrite); + } catch (err) { + // Race: another tab just wrote the same id. Re-fetch and return + // that tab's record. + const reload = await getAgent(id); + if (reload) return reload; + throw err; + } + return agent; } /**