mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 09:59:41 +02:00
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:
parent
68c0eb2892
commit
a36e543e41
1 changed files with 97 additions and 23 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue