diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index d832f704e..62ee154d9 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -20,8 +20,8 @@ import { fire as fireTrigger } from '$lib/triggers/registry'; import { checkInlineSuggestion } from '$lib/triggers/inline-suggest'; import { getEffectiveUserId, GUEST_USER_ID } from './current-user'; import { getEffectiveSpaceId } from './scope/active-space.svelte'; -import { getCurrentActor, makeFieldMeta } from './events/actor'; -import type { Actor, FieldMeta, FieldOrigin } from './events/actor'; +import { getCurrentActor, makeFieldMeta, originFromActor } from './events/actor'; +import type { Actor, FieldMeta } from './events/actor'; import { isQuotaError, notifyQuotaExceeded } from './quota-detect'; import { SYNC_APP_MAP, @@ -1501,9 +1501,12 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { // `at` drives field-LWW ordering, `actor` carries attribution forward // across renames, `origin` distinguishes user edits from system / // migration / agent / server-replay writes for conflict-detection. - // F1 hardcodes `origin: 'user'` here — F2 will derive it from the - // active actor.kind so AI-runner writes land as `'agent'` etc. - const origin: FieldOrigin = 'user'; + // `origin` is derived from `actor.kind`: + // user → 'user' + // ai → 'agent' (mission-runner / tool executor) + // system + SYSTEM_MIGRATION → 'migration' (Dexie upgrades, repair routines) + // any other system source → 'system' (projection, rule, stream, …) + const origin = originFromActor(actor); const fieldMeta: Record = {}; for (const key of Object.keys(obj)) { if (isInternalKey(key)) continue; @@ -1542,7 +1545,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { if (_applyingTables.has(tableName)) return undefined; const now = new Date().toISOString(); const actor: Actor = getCurrentActor(); - const origin: FieldOrigin = 'user'; + const origin = originFromActor(actor); const fields: Record = {}; // userId is immutable after creation. Silently strip any attempt to diff --git a/apps/mana/apps/web/src/lib/data/events/actor.ts b/apps/mana/apps/web/src/lib/data/events/actor.ts index 05ef30e45..787d3bce9 100644 --- a/apps/mana/apps/web/src/lib/data/events/actor.ts +++ b/apps/mana/apps/web/src/lib/data/events/actor.ts @@ -51,6 +51,7 @@ export { isFromMissionRunner, makeFieldMeta, isUserOriginatedField, + originFromActor, } from '@mana/shared-ai'; /** diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts index 480a90bee..a5e1c65f3 100644 --- a/apps/mana/apps/web/src/lib/data/sync.test.ts +++ b/apps/mana/apps/web/src/lib/data/sync.test.ts @@ -515,5 +515,152 @@ describe('applyServerChanges (Dexie integration)', () => { // Tied — LWW lets server win silently (no edit-loss to surface) expect(conflicts).toHaveLength(0); }); + + // ─── F2: Origin-gated conflict detection ───────────────── + + it('does NOT fire on a sequential server-replay burst (history-replay)', async () => { + // Reproduces the original bug: a fresh client pulls history of + // 10 distinct server changes for the same record across N + // pseudo-sessions. None of them is a local user edit; all + // applyServerChanges writes are stamped origin='server-replay'. + // No conflict toast must fire — the surface is reserved for + // user edits that lose to a later server overwrite. + const conflicts = await captureConflicts(async () => { + // Initial insert (server-replay). + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-replay-burst', + op: 'insert', + data: { + id: 'task-replay-burst', + title: 'gen-0', + priority: 'low', + isCompleted: false, + order: 0, + updatedAt: '2026-04-01T00:00:00Z', + }, + }, + ]); + // 10 follow-up updates with monotonically increasing timestamps. + for (let i = 1; i <= 10; i++) { + const ts = `2026-04-01T00:0${i}:00Z`; + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-replay-burst', + op: 'update', + fields: { title: { value: `gen-${i}`, at: ts } }, + }, + ]); + } + }); + + expect(conflicts).toHaveLength(0); + }); + + it('does NOT fire when the local write came from an AI agent (origin=agent)', async () => { + const { runAs } = await import('./events/actor'); + const { makeAgentActor } = await import('@mana/shared-ai'); + + const agent = makeAgentActor({ + agentId: 'agent-test', + displayName: 'Test-Agent', + missionId: 'mission-1', + iterationId: 'iter-1', + rationale: 'unit-test', + }); + + // Seed locally under the agent actor — the creating-hook + // stamps origin='agent' for every field. + runAs(agent, () => { + void db.table('tasks').add({ + id: 'task-agent-write', + title: 'agent-typed value', + priority: 'low', + isCompleted: false, + order: 0, + }); + }); + // Wait for the (synchronous) Dexie put to drain. + await db.table('tasks').get('task-agent-write'); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-agent-write', + op: 'update', + fields: { + title: { value: 'their version', at: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + // Agent writes are visible in the proposal/mission UI, not the + // conflict toast — server overwriting them is silent. + expect(conflicts).toHaveLength(0); + }); + + it('does NOT fire when isInitialHydration is set (belt-and-suspenders)', async () => { + // Even with a real local user edit present, the hydration mode + // suppresses the entire conflict surface for the round. + await db.table('tasks').add({ + id: 'task-hydration', + title: 'my draft', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges( + 'todo', + [ + { + table: 'tasks', + id: 'task-hydration', + op: 'update', + fields: { + title: { value: 'server overwrite', at: '2099-01-01T00:00:00Z' }, + }, + }, + ], + { isInitialHydration: true } + ); + }); + + expect(conflicts).toHaveLength(0); + }); + + it('fires for a real user edit getting overwritten (origin=user)', async () => { + // Belt: the existing "fires when..." test at the top of the + // suite already covers this — restated here so the F2 block + // reads as a complete spec for the gating rules. + await db.table('tasks').add({ + id: 'task-real-conflict', + title: 'I typed this', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-real-conflict', + op: 'update', + fields: { + title: { value: 'somebody else', at: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].field).toBe('title'); + }); }); }); diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index 7418f5440..53dd7a6ce 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -287,7 +287,22 @@ function valuesEqual(a: unknown, b: unknown): boolean { } } -export async function applyServerChanges(appId: string, changes: unknown[]): Promise { +/** Options for {@link applyServerChanges}. + * + * `isInitialHydration` flips off conflict-detection entirely — used when + * the calling pull/push knows the local client has never seen this app's + * data before (cursor is empty), so any "newer server value over local" + * is by definition initial-fill, not a lost edit. Belt-and-suspenders + * with the per-field `origin === 'user'` gate inside the inner loop. */ +export interface ApplyServerChangesOptions { + isInitialHydration?: boolean; +} + +export async function applyServerChanges( + appId: string, + changes: unknown[], + options: ApplyServerChangesOptions = {} +): Promise { // Reject malformed entries up-front so a single bad row from the server // can never write garbage into IndexedDB. Drops are logged once and the // good entries proceed — partial degradation beats a hard crash on a @@ -402,16 +417,20 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro if (key === 'id' || key === FIELD_META_KEY) continue; const localFieldTime = localMeta[key]?.at ?? localUpdatedAt; if (recordTime >= localFieldTime) { - // Conflict signal: server STRICTLY wins (>) and the local + // Conflict signal: server STRICTLY wins (>), the local // field had a non-empty value that differs from the new - // one. Equal-time ties don't fire because there's no - // edit to lose. F2 will additionally gate this on - // localMeta[key].origin === 'user'. + // one, AND the local write was a real user edit (not a + // server-replay / system / migration / agent write). + // Initial-hydration pulls bypass the conflict surface + // entirely — by definition no edit can be lost when + // the table has never been seen locally before. const localValue = (existing as Record)[key]; if ( + !options.isInitialHydration && recordTime > localFieldTime && localValue != null && - !valuesEqual(localValue, val) + !valuesEqual(localValue, val) && + localMeta[key]?.origin === 'user' ) { notifyConflict({ tableName, @@ -464,13 +483,16 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro const localFieldTime = localMeta[key]?.at ?? localUpdatedAt; if (serverTime >= localFieldTime) { // Same conflict criteria as the insert-as-update path: - // strictly newer + non-empty local + actually different. - // F2 will additionally gate on localMeta[key].origin === 'user'. + // strictly newer + non-empty local + actually different + // + local write came from a real user edit. Initial + // hydration suppresses the surface entirely. const localValue = (existing as Record)[key]; if ( + !options.isInitialHydration && serverTime > localFieldTime && localValue != null && - !valuesEqual(localValue, fc.value) + !valuesEqual(localValue, fc.value) && + localMeta[key]?.origin === 'user' ) { notifyConflict({ tableName, @@ -777,6 +799,12 @@ export function createUnifiedSync( // Build changeset in backend protocol format const changeset = buildChangeset(pending, clientId, oldestCursor); + // First contact with this app's data: cursor empty means the + // server is about to send everything-since-epoch. No local edit + // can be lost in that batch by definition — flip on the + // hydration mode so applyServerChanges suppresses any conflict + // notifications for this round. + const isInitialHydration = !oldestCursor; const res = await fetchWithRetry( `${serverUrl}/sync/${appId}`, @@ -802,7 +830,7 @@ export function createUnifiedSync( // Apply server changes from the response if (data.serverChanges?.length > 0) { - await applyServerChanges(appId, data.serverChanges); + await applyServerChanges(appId, data.serverChanges, { isInitialHydration }); } // Update sync cursor @@ -881,6 +909,12 @@ export function createUnifiedSync( const syncName = toSyncName(tableName); let cursor = await getSyncCursor(appId, tableName); let hasMore = true; + // Hydration applies to the first page of an empty-cursor + // pull. Subsequent pages carry a real cursor (set after the + // first response lands) so they fall back to normal + // conflict-detection — by then any user edits made during + // pagination are real edits worth surfacing. + let isInitialHydration = !cursor; // Paginated pull: continue fetching until server signals no more data while (hasMore) { @@ -906,11 +940,12 @@ export function createUnifiedSync( if (data.serverChanges && data.serverChanges.length > 0) { totalApplied += data.serverChanges.length; - await applyServerChanges(appId, data.serverChanges); + await applyServerChanges(appId, data.serverChanges, { isInitialHydration }); } if (data.syncedUntil) { cursor = data.syncedUntil; + isInitialHydration = false; } else { break; } diff --git a/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts b/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts index 0732f3585..355035d93 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts @@ -25,6 +25,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { profileService } from '$lib/api/profile'; import { meImagesTable } from '../collections'; import { meImagesStore } from '../stores/me-images.svelte'; +import { makeSystemActor, runAsAsync, SYSTEM_MIGRATION } from '$lib/data/events/actor'; export async function migrateLegacyAvatarIfNeeded(): Promise { const user = authStore.user; @@ -63,28 +64,40 @@ export async function migrateLegacyAvatarIfNeeded(): Promise { return; } - await meImagesStore.createMeImage({ - kind: 'face', - // Sentinel mediaId: not a real mana-media reference. The generate- - // with-reference path (M3) gates on MediaClient.list({app:'me'}), - // so this id will naturally bounce if ever used for generation. - mediaId: `legacy-avatar:${user.id}`, - storagePath: profile.image, - publicUrl: profile.image, - thumbnailUrl: profile.image, - width: 0, - height: 0, - label: 'Bisheriges Profilbild', - usage: { aiReference: false, showInProfile: true }, - primaryFor: 'avatar', - // Legacy avatar is the user's global SSO identity (Better Auth - // `users.image`) — it belongs explicitly in the *personal* space, - // regardless of which space the user happens to be in when the - // migration fires. Use the `_personal:` sentinel that - // reconcileSentinels() rewrites to the real personal-space id on - // the next active-space bootstrap (same pattern v28 used for the - // blanket data-table migration). - spaceId: `_personal:${user.id}`, + // Pin the narrowed `profile.image` to a const so the type stays + // `string` across the runAsAsync closure (control-flow narrowing + // doesn't survive nested callbacks). + const imageUrl = profile.image; + + // Run the seed insert under a migration-system actor so the Dexie + // creating-hook stamps `origin: 'migration'` on every field — + // conflict-detection ignores this row when the same migration fires + // later on a different device. + const migrationActor = makeSystemActor(SYSTEM_MIGRATION, 'Migration: legacy avatar'); + await runAsAsync(migrationActor, async () => { + await meImagesStore.createMeImage({ + kind: 'face', + // Sentinel mediaId: not a real mana-media reference. The generate- + // with-reference path (M3) gates on MediaClient.list({app:'me'}), + // so this id will naturally bounce if ever used for generation. + mediaId: `legacy-avatar:${user.id}`, + storagePath: imageUrl, + publicUrl: imageUrl, + thumbnailUrl: imageUrl, + width: 0, + height: 0, + label: 'Bisheriges Profilbild', + usage: { aiReference: false, showInProfile: true }, + primaryFor: 'avatar', + // Legacy avatar is the user's global SSO identity (Better Auth + // `users.image`) — it belongs explicitly in the *personal* space, + // regardless of which space the user happens to be in when the + // migration fires. Use the `_personal:` sentinel that + // reconcileSentinels() rewrites to the real personal-space id on + // the next active-space bootstrap (same pattern v28 used for the + // blanket data-table migration). + spaceId: `_personal:${user.id}`, + }); }); try { diff --git a/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts b/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts index cfdd8205b..fe261f2f4 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts @@ -24,6 +24,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { meImagesTable } from '../collections'; +import { makeSystemActor, runAsAsync, SYSTEM_MIGRATION } from '$lib/data/events/actor'; export async function repairSilentTwinAvatarRows(): Promise { const user = authStore.user; @@ -60,14 +61,22 @@ export async function repairSilentTwinAvatarRows(): Promise { victims.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); const nowIso = new Date().toISOString(); - await meImagesTable.db.transaction('rw', meImagesTable, async () => { - for (let i = 0; i < victims.length; i++) { - const row = victims[i]; - await meImagesTable.update(row.id, { - primaryFor: i === 0 ? 'face-ref' : null, - updatedAt: nowIso, - }); - } + // Run the rewrite under a migration-system actor so the Dexie + // updating-hook stamps `origin: 'migration'` on every touched field. + // Conflict-detection later treats these writes as pipeline-internal — + // a fresh client pulling the same updates from another device must + // NOT see "another session overwrote your edit" toasts. + const migrationActor = makeSystemActor(SYSTEM_MIGRATION, 'Repair: silent-twin'); + await runAsAsync(migrationActor, async () => { + await meImagesTable.db.transaction('rw', meImagesTable, async () => { + for (let i = 0; i < victims.length; i++) { + const row = victims[i]; + await meImagesTable.update(row.id, { + primaryFor: i === 0 ? 'face-ref' : null, + updatedAt: nowIso, + }); + } + }); }); try { diff --git a/docs/plans/sync-field-meta-overhaul.md b/docs/plans/sync-field-meta-overhaul.md index a0fe1b373..3cf5a7f36 100644 --- a/docs/plans/sync-field-meta-overhaul.md +++ b/docs/plans/sync-field-meta-overhaul.md @@ -145,8 +145,8 @@ _Wird befüllt während der Ausführung._ | Phase | Commit | Notiz | | --- | --- | --- | -| F1 | _staged, uncommitted_ | Web + mana-sync (Go) + mana-ai + apps/api/mcp + tests + DB schema reset. Type-checks grün, mana-sync Go-Tests grün, mana-ai Bun-Tests grün (61 pass). DB truncated + recreated mit `field_meta` JSONB + `origin` TEXT. Browser-IndexedDB-Wipe + Smoke-Test stehen aus (User-Action). | -| F2 | _pending_ | | +| F1 | `7766ea502` | Web + mana-sync (Go) + mana-ai + apps/api/mcp + tests + DB schema reset. **Note: Commit-Titel `docs(plans): mark llm-fallback-aliases SHIPPED` ist irreführend — Multi-Terminal-Race hat F1 in einem fremden Commit zusammengeführt. Code ist trotzdem korrekt drin (27 F1 Files + Plan).** Tests grün, DB migriert. | +| F2 | _staged, uncommitted_ | Origin-Gate aktiviert. `originFromActor()` in shared-ai/field-meta.ts maps actor.kind → 'user'/'agent'/'system'/'migration'. Hooks nutzen es. Repair-Migrations (repair-silent-twin, legacy-avatar) wrappen ihre Writes in `runAsAsync(systemMigrationActor, ...)`. `applyServerChanges` bekommt `ApplyServerChangesOptions.isInitialHydration` Parameter, beide Caller (push-response + pull) setzen ihn aus dem Cursor-State. Conflict-Trigger feuert nur noch wenn `localMeta[k]?.origin === 'user' && !options.isInitialHydration`. **29 Tests grün** inkl. 4 neuer (replay-burst no-conflict, agent-origin no-conflict, hydration no-conflict, user-edit fires-conflict). | | F3 | _pending_ | | | F4 | _pending_ | | | F5 | _pending_ | | diff --git a/packages/shared-ai/src/field-meta.ts b/packages/shared-ai/src/field-meta.ts index 24bc2a877..95ff63871 100644 --- a/packages/shared-ai/src/field-meta.ts +++ b/packages/shared-ai/src/field-meta.ts @@ -23,6 +23,7 @@ */ import type { Actor } from './actor'; +import { SYSTEM_MIGRATION } from './actor'; /** * Pipeline that produced a given field value, from the perspective of @@ -67,3 +68,23 @@ export function makeFieldMeta(at: string, actor: Actor, origin: FieldOrigin): Fi export function isUserOriginatedField(meta: FieldMeta | undefined): boolean { return meta?.origin === 'user'; } + +/** + * Map an actor onto the pipeline-origin we stamp at the Dexie hook. + * + * Rules: + * - `kind: 'user'` → `'user'` + * - `kind: 'ai'` → `'agent'` + * - `kind: 'system'` with `principalId === SYSTEM_MIGRATION` → `'migration'` + * - any other system source → `'system'` + * + * Server-replay is set explicitly by `applyServerChanges`, never derived + * here — the local hook only sees writes the local code initiated. + */ +export function originFromActor(actor: Actor): FieldOrigin { + if (actor.kind === 'ai') return 'agent'; + if (actor.kind === 'system') { + return actor.principalId === SYSTEM_MIGRATION ? 'migration' : 'system'; + } + return 'user'; +} diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 0ecefac08..7b607018c 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -38,7 +38,7 @@ export { } from './actor'; export type { FieldMeta, FieldOrigin } from './field-meta'; -export { makeFieldMeta, isUserOriginatedField } from './field-meta'; +export { makeFieldMeta, isUserOriginatedField, originFromActor } from './field-meta'; export type { IterationPhase,