feat(agents): Phase 2d.3 — SpaceType-aware default agent bootstrap

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:<spaceId>` (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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:40:28 +02:00
parent 68c0eb2892
commit a36e543e41

View file

@ -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:<spaceId>`) 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<SpaceType, string> = {
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<SpaceType, string> = {
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:<spaceId>` 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<Agent> {
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;
}
/**