/** Concurrency, retry, and validation tests for settings saves, pause toggling, and policy synchronization. */ import { afterEach, expect, it, vi } from 'vitest' import { UserError } from '../src/core/index.ts' import { Commands } from '../src/infra/commands.ts' import { Setup } from '../src/app/setup.ts' import { SettingsConflict } from '../src/app/settings.ts' import { startRuntime, type Host } from './fixtures/host.ts' let h: Host | undefined afterEach(async () => { await h?.stop(); h = undefined }) function deferred() { let resolve!: (value: T) => void const promise = new Promise(settle => { resolve = settle }) return { promise, resolve } } const model = { provider: 'worker-provider', model: 'worker-model' } it('refuses a save of a block that another save changed during its model or account check, and saves nothing of it', async () => { h = await startRuntime() const { settings, store } = h.runtime const llm = (h.ctx as unknown as { llm: { resolveCallConfig: ReturnType } }).llm const check = deferred() llm.resolveCallConfig.mockImplementationOnce(() => check.promise) const executor = store.settings().executor const revision = store.blockRevision('executor') const slow = settings.save('executor', { ...executor, model, maxWorkers: 5 }, revision, 'panel') await settings.save('executor', { ...executor, maxWorkers: 2 }, revision, 'panel') check.resolve(model) await expect(slow).rejects.toThrow(SettingsConflict) expect(store.settings().executor).toMatchObject({ maxWorkers: 2, model: executor.model }) // An account check that a save of the same Source outruns is refused the same way. const accounts = deferred() vi.spyOn(Setup.prototype, 'checkAccounts').mockImplementationOnce(() => accounts.promise) const github = settings.block('source:github') const value = github.value as { enabled: boolean; config: Record } const moved = settings.save('source:github', { ...value, config: { ...value.config, cli: '/opt/bin/gh-other' } }, github.revision, 'panel') await settings.save('source:github', { ...value, config: { ...value.config, pollSeconds: 90 } }, github.revision, 'panel') accounts.resolve() await expect(moved).rejects.toThrow(SettingsConflict) expect(store.settings().sources.github!.config).toMatchObject({ cli: value.config.cli, pollSeconds: 90 }) }) it('refuses a Policy sync that a Policy save outran, reports it on sync:feishu, and keeps the save', async () => { h = await startRuntime() const { settings, store } = h.runtime const members = deferred() vi.mocked(Commands.prototype.lark).mockImplementationOnce(() => members.promise as never) const syncing = settings.sync('feishu') const policy = store.settings().policy const rules = [{ id: 'docs', name: 'Docs', enabled: true, instruction: 'Fix docs' }] await settings.save('policy', { ...policy, rules }, store.blockRevision('policy'), 'panel') members.resolve({ users: [{ member_id: 'ou_alice', name: 'Alice' }, { member_id: 'ou_new', name: 'New' }], has_more: false }) await expect(syncing).rejects.toThrow(SettingsConflict) expect(h.runtime.health['sync:feishu']).toMatchObject({ state: 'error', detail: expect.stringContaining('Policy changed during synchronization') }) expect(store.settings().policy).toMatchObject({ rules, sources: { feishu: { allowed: [{ id: 'ou_alice', name: 'Alice' }] } } }) }) it('syncs a Policy part again after a failed sync, a minute later and then after a wait that doubles, until it succeeds', async () => { h = await startRuntime() const { settings } = h.runtime // The syncs the Runtime runs as it starts finish first. await vi.waitFor(() => expect(h!.runtime.health['sync:feishu']).toMatchObject({ state: 'ready' })) await h.runtime.idle() vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { const lark = vi.mocked(Commands.prototype.lark) lark.mockRejectedValueOnce(new Error('network down')).mockRejectedValueOnce(new Error('network down')) await expect(settings.sync('feishu')).rejects.toThrow('network down') expect(h.runtime.health['sync:feishu']).toMatchObject({ state: 'error', detail: expect.stringContaining('network down') }) const calls = () => lark.mock.calls.filter(([args]) => args.includes('+chat-members-list')).length const before = calls() await vi.advanceTimersByTimeAsync(60_000) // The first retry fails too, so the next waits two minutes. expect(calls()).toBe(before + 1) lark.mockResolvedValueOnce({ users: [{ member_id: 'ou_alice', name: 'Alice' }], has_more: false }) await vi.advanceTimersByTimeAsync(60_000) expect(calls()).toBe(before + 1) await vi.advanceTimersByTimeAsync(60_000) await vi.waitFor(() => expect(h!.runtime.health['sync:feishu']).toMatchObject({ state: 'ready' })) expect(calls()).toBe(before + 2) // A success ends the retries. await vi.advanceTimersByTimeAsync(3_600_000) expect(calls()).toBe(before + 2) } finally { vi.useRealTimers() } }) it('records nothing for a pause that changes nothing, refuses a sync while setup waits, and names an unknown block', async () => { h = await startRuntime() const { settings, store } = h.runtime const revision = store.blockRevision('pause') const inject = h.agents.get(h.runtime.masterId)!.inject inject.mockClear() expect(await settings.pause(false, 'panel')).toEqual({ paused: false }) await h.runtime.idle() expect([store.blockRevision('pause'), inject.mock.calls.length, h.runtime.effects.pending('notice')]).toEqual([revision, 0, false]) vi.spyOn(Setup.prototype, 'configured').mockReturnValue(false) await expect(settings.sync('feishu')).rejects.toThrow(new UserError('nothingToSync', { source: 'feishu' })) vi.mocked(Setup.prototype.configured).mockRestore() expect(() => settings.block('source:x')).toThrow(new UserError('unknownBlock', { block: 'source:x' })) })