From a36e543e418695eee0b0410cfcb936eff3639927 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 17:40:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(agents):=20Phase=202d.3=20=E2=80=94=20Spac?= =?UTF-8?q?eType-aware=20default=20agent=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, the bootstrap created one "Mana" agent per user. After per-Space migration, every Space needs its own default agent so Actor attribution shows the right identity and missions land in the right Space. Three users with Personal + Family + Brand Spaces would have ended up with three "Mana" agents in the picker — ugly and confusing. Now each Space type gets a name that reads naturally: - personal → "Mana" (keeps legacy name + DEFAULT_AGENT_ID so historic Actor.displayName on pre-migration records still renders) - family → "Familien-Helfer" - team → "Team-Assistent" - brand → "Brand-Assistent" - club → "Verein-Helfer" - practice → "Praxis-Assistent" Stable id scheme: - Personal: DEFAULT_AGENT_ID (legacy coupling with LEGACY_AI_PRINCIPAL) - Others: `default:` (deterministic, collision-free) Bootstrap bypasses the regular createAgent path (which enforces global name-uniqueness) because the same name is legitimately repeated across multiple Spaces of the same type. Deduplication happens via getAgent(id) + Dexie's add-or-skip for cross-tab races instead. ensureDefaultAgent() reads the active Space via getActiveSpace(); when no Space is loaded yet (pre-bootstrap first boot) it falls back to the Personal default. The per-Space re-run on onActiveSpaceChanged (Phase 2d.4) picks up the correct agent once loadActiveSpace resolves. Type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/ai/agents/bootstrap.ts | 120 ++++++++++++++---- 1 file changed, 97 insertions(+), 23 deletions(-) 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; } /**