import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { DatabaseSync } from 'node:sqlite' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { Store, SCHEMA_VERSION } from '../src/app/store.ts' import { MemoryRepositories, key, type Assignment, type Decision, type Repositories, type Thread, type Ticket } from '../src/core/index.ts' import { threadFacts } from '../src/app/views/records.ts' import { KV } from '../src/kv.ts' import { SettingsSchema } from '../src/policy.ts' let directory: string beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'orchestrator-store-')) }) afterEach(() => { rmSync(directory, { recursive: true, force: true }) }) it('refuses databases newer than this build and rolls back failed atomic work', () => { const path = join(directory, 'orchestrator.sqlite') const store = new Store(path) expect(() => store.atomic(() => { store.set('a', 1); throw new Error('boom') })).toThrow('boom') expect(store.get('a')).toBeUndefined() store.set('schema', SCHEMA_VERSION + 1); store.close() expect(() => new Store(path)).toThrow('Unsupported') }) it('finds triggers by thread', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) store.triggers.save({ id: 'a', source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_%_x' }], actor: { source: 'feishu', id: 'u' }, human: true, at: '2', text: 'later', meta: {} }) store.triggers.save({ id: 'b', source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_%_x' }, { source: 'github', id: '3' }], actor: { source: 'feishu', id: 'u' }, human: true, at: '1', text: 'earlier', meta: {} }) store.triggers.save({ id: 'c', source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_y_x' }], actor: { source: 'feishu', id: 'u' }, human: true, at: '3', text: 'other', meta: {} }) expect(store.read.threadTriggers('feishu:oc_1:om_%_x').map(trigger => trigger.id)).toEqual(['b', 'a']) expect(store.read.threadTriggers('github:3').map(trigger => trigger.id)).toEqual(['b']) // Saving a Trigger again replaces the Threads it is found on. store.triggers.save({ ...store.triggers.get('b')!, threads: [{ source: 'github', id: '3' }] }) expect(store.read.threadTriggers('feishu:oc_1:om_%_x').map(trigger => trigger.id)).toEqual(['a']) expect(store.read.threadTriggers('github:3').map(trigger => trigger.id)).toEqual(['b']) expect(store.read.subscribedThreads()).toEqual([]) store.close() }) const ticket = (id: string, state: 'open' | 'ended', assignments: Pick[], undelivered: string[] = []): Ticket => ({ id, title: id, decisionId: '', mandate: { strength: 'explicit', policyVersion: '', basis: { via: 'listed' }, actor: { source: 'x', id: '' }, scope: '' }, channels: [], assignments: assignments.map((item, index) => ({ id: `${id}-${index}`, createdAt: '', ...item })), inputs: [], ...state === 'ended' ? { ended: { at: '', by: 'executor' as const, reason: 'closed', outcome: 'closed' as const } } : {}, undelivered, createdAt: '', updatedAt: '', origin: { triggerId: '', triggerIds: [] } }) const thread = (id: string, source: string, fields: Partial = {}): Thread => ({ key: `${source}:${id}`, ref: { source, id }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: {}, ...fields }) const ids = (items: { id?: string; key?: string }[]) => items.map(item => item.id ?? item.key).sort() it('reads the working sets alone, in SQLite as in memory, and follows each save', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) for (const repo of [store, new MemoryRepositories()] as Repositories[]) { repo.tickets.save(ticket('running', 'open', [{ state: 'running' }])) // A retry or a resume adds a pending Assignment after the ones that ended. repo.tickets.save(ticket('queued', 'open', [{ state: 'ended' }, { state: 'pending' }])) // An ended Ticket a person's message reached without resuming it, which versions before schema 11 left to their next pass. repo.tickets.save(ticket('waking', 'ended', [{ state: 'ended' }], ['input'])) repo.tickets.save(ticket('done', 'ended', [{ state: 'ended' }])) repo.threads.save(thread('1', 'github', { pending: ['a'] })) repo.threads.save(thread('2', 'github', { subscriptions: ['running'], evaluating: { id: 'e', threadKey: 'github:2', triggerIds: ['b'], strength: 'explicit', actor: { source: 'github', id: 'alice' }, basis: { via: 'followup' }, policyVersion: '', startedAt: '', deadline: '' } })) repo.threads.save(thread('oc:om', 'feishu', { subscriptions: ['done'] })) repo.threads.save(thread('3', 'github')) expect(ids(repo.tickets.open())).toEqual(['queued', 'running']) expect(ids(repo.tickets.active())).toEqual(['queued', 'running', 'waking']) expect(ids(repo.threads.active())).toEqual(['github:1', 'github:2']) repo.tickets.save(ticket('running', 'ended', [{ state: 'ended' }])) repo.threads.save(thread('1', 'github')) expect(ids(repo.tickets.open())).toEqual(['queued']) expect(ids(repo.tickets.active())).toEqual(['queued', 'waking']) expect(ids(repo.threads.active())).toEqual(['github:2']) } expect(ids(store.read.subscribedThreads())).toEqual(['feishu:oc:om', 'github:2']) expect(ids(store.read.subscribedThreads('github'))).toEqual(['github:2']) store.close() }) it('adds the working sets to a database that predates them, from the rows it holds', () => { const path = join(directory, 'orchestrator.sqlite') const db = new DatabaseSync(path) db.exec(`CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE ticket (id TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE thread (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) db.prepare('INSERT INTO kv VALUES (?,?)').run('schema', String(SCHEMA_VERSION)) db.prepare('INSERT INTO ticket VALUES (?,?)').run('t', JSON.stringify(ticket('t', 'open', [{ state: 'running' }]))) db.prepare('INSERT INTO thread VALUES (?,?)').run('github:1', JSON.stringify(thread('1', 'github', { pending: ['a'], subscriptions: ['t'] }))) db.close() const store = new Store(path) expect([ids(store.tickets.open()), ids(store.tickets.active())]).toEqual([['t'], ['t']]) expect([ids(store.threads.active()), ids(store.read.subscribedThreads('github'))]).toEqual([['github:1'], ['github:1']]) store.close() }) it('names a thread by its first human trigger', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) const base = { source: 'github', threads: [{ source: 'github', id: '5' }], actor: { source: 'github', id: 'x' }, meta: {} } store.triggers.save({ ...base, id: 'ci', human: false, at: '1', text: 'GitHub PR #5 CI: {}' }) store.triggers.save({ ...base, id: 'person', human: true, at: '2', text: 'please look' }) expect(store.read.firstTrigger('github:5')?.id).toBe('ci') expect(store.read.firstTrigger('github:5', true)?.id).toBe('person') store.close() }) it('counts triggers per Source and lists them newest first, optionally only the filtered ones', () => { const store = new Store(join(directory, 'db.sqlite')) const trigger = (id: string, source: string, at: string) => ({ id, source, threads: [{ source, id: 'x' }], actor: { source, id: 'a' }, human: true, at, text: id, meta: {} }) store.triggers.save(trigger('old', 'feishu', '2026-09-01T00:00:00Z')) store.triggers.save(trigger('kept', 'feishu', '2026-09-23T00:00:00Z')) store.triggers.save(trigger('dropped', 'feishu', '2026-09-23T01:00:00Z')) store.triggers.saveDisposition('dropped', { filtered: { code: 'chat_not_allowed' }, fanout: [], at: '' }) store.triggers.save(trigger('gh', 'github', '2026-09-22T00:00:00Z')) expect(store.read.triggerStats('2026-09-22T12:00:00Z')).toEqual({ feishu: { received: 2, filtered: 1, lastAt: '2026-09-23T01:00:00Z' }, github: { received: 0, filtered: 0, lastAt: '2026-09-22T00:00:00Z' } }) expect(store.read.triggers({ source: 'feishu' }, 0, 2)).toEqual({ items: [expect.objectContaining({ id: 'dropped' }), expect.objectContaining({ id: 'kept' })], more: true }) expect(store.read.triggers({ filtered: true }).items.map(item => item.id)).toEqual(['dropped']) store.close() }) it('links a Thread to the page its Source named, or else to the message that started it', () => { const store = new Store(join(directory, 'links.sqlite')) const thread = (keyValue: string, source: string, id: string, meta: Record = {}) => store.threads.save({ key: keyValue, ref: { source, id }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta }) thread('github:7', 'github', '7', { url: 'https://github.com/acme/widgets/pull/7' }) thread('feishu:oc_1:om_1', 'feishu', 'oc_1:om_1') store.triggers.save({ id: 'f1', source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_1' }], actor: { source: 'feishu', id: 'ou_a' }, human: true, at: '1', text: 'hi', url: 'https://applink.feishu.cn/client/message/link/open?token=t', meta: {} }) expect(threadFacts(store, store.threads.get('github:7')!).url).toBe('https://github.com/acme/widgets/pull/7') expect(threadFacts(store, store.threads.get('feishu:oc_1:om_1')!).url).toBe('https://applink.feishu.cn/client/message/link/open?token=t') store.close() }) it('sets and removes a Source\'s keys in a Thread\'s meta', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) store.threads.save({ key: 'github:5', ref: { source: 'github', id: '5' }, createdAt: '', subscriptions: [], correlated: [], pending: [], meta: { isPr: true, gone: true } }) store.threads.patchMeta('github:5', { gone: undefined, title: 'Fix' }) expect(store.threads.get('github:5')!.meta).toEqual({ isPr: true, title: 'Fix' }) store.threads.patchMeta('github:6', { title: 'none' }) expect(store.threads.get('github:6')).toBeUndefined() store.close() }) it('spells every kv key in KV alone, so each record is named by its entry there', () => { const src = join(dirname(fileURLToPath(import.meta.url)), '..', 'src') const entries = Object.values(KV).flatMap(group => Object.values(group) as (string | { prefix: string })[]) // Ignore single-word keys that collide with unrelated string literals. const keys = entries.map(entry => typeof entry === 'string' ? entry : entry.prefix).filter(name => /[-:]/.test(name)) const modules = readdirSync(src, { recursive: true, encoding: 'utf8' }) .filter(path => /\.tsx?$/.test(path) && !['kv.ts', join('app', 'migration.ts')].includes(path)) const spelled = modules.flatMap(path => { const text = readFileSync(join(src, path), 'utf8') return keys.filter(name => text.includes(`'${name}`) || text.includes(`\`${name}`)).map(name => `${path}: ${name}`) }) expect(spelled).toEqual([]) }) it('runs what waits for a commit once the outermost transaction commits, and never what a rolled back one queued', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) const ran: string[] = [] store.atomic(() => { store.afterCommit(() => { ran.push('outer') }) store.atomic(() => { store.afterCommit(() => { ran.push('inner') }) }) expect(() => store.atomic(() => { store.afterCommit(() => { ran.push('rolled back') }); throw new Error('boom') })).toThrow('boom') // Nothing runs before the outermost transaction commits. expect(ran).toEqual([]) }) expect(ran).toEqual(['outer', 'inner']) expect(() => store.atomic(() => { store.afterCommit(() => { ran.push('failed') }); throw new Error('boom') })).toThrow('boom') store.afterCommit(() => { ran.push('outside') }) expect(ran).toEqual(['outer', 'inner', 'outside']) store.close() }) it('reports an action that fails after a commit, runs the others, and keeps what committed', () => { const reported: unknown[] = [] const store = new Store(join(directory, 'orchestrator.sqlite'), error => { reported.push(error) }) const ran: string[] = [] const value = store.atomic(() => { store.set(KV.runtime.masterId, 'm') store.afterCommit(() => { throw new Error('cancel failed') }) store.afterCommit(() => { ran.push('next') }) return 'done' }) expect(value).toBe('done') expect(ran).toEqual(['next']) expect(reported).toEqual([new Error('cancel failed')]) expect(store.get(KV.runtime.masterId)).toBe('m') store.close() }) it('closes a database it cannot open as this version\'s, so a later attempt can open it', () => { const path = join(directory, 'orchestrator.sqlite') const store = new Store(path) store.set(KV.store.schema, SCHEMA_VERSION + 1) store.close() const close = vi.spyOn(DatabaseSync.prototype, 'close') try { expect(() => new Store(path)).toThrow('Unsupported orchestrator database version') expect(close).toHaveBeenCalledOnce() } finally { close.mockRestore() } }) it('keeps the settings as versions: the current settings are the snapshot of the current revision, kept nowhere else', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) expect(store.settings()).toEqual(SettingsSchema.parse({})) store.saveSettings({ ...store.settings(), paused: true }, { source: 'panel', block: 'pause', policyVersion: 'v', policyChanged: false, notified: [] }) store.saveSettings({ ...store.settings(), executor: { ...store.settings().executor, maxWorkers: 5 } }) expect(store.settings()).toMatchObject({ paused: true, executor: { maxWorkers: 5 } }) expect(store.settingsHistory().map(version => [version.revision, version.source])).toEqual([[2, 'internal'], [1, 'panel']]) expect([store.get(KV.settings.revision), store.blockRevision('pause'), store.keys('settings').filter(name => name === 'settings')]).toEqual([2, 1, []]) store.close() }) it('replaces a working-set column an earlier build defined otherwise, and drops one this version no longer has', () => { const path = join(directory, 'orchestrator.sqlite') const db = new DatabaseSync(path) db.exec(`CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE ticket (id TEXT PRIMARY KEY, value TEXT NOT NULL); ALTER TABLE ticket ADD COLUMN open INTEGER AS (CASE WHEN value ->> '$.state' = 'open' THEN 1 END); ALTER TABLE ticket ADD COLUMN ref TEXT AS (nullif(value ->> '$.assignments[#-1].ref', '')); CREATE INDEX ticket_open ON ticket(open) WHERE open IS NOT NULL; CREATE INDEX ticket_ref ON ticket(ref) WHERE ref IS NOT NULL`) db.prepare('INSERT INTO kv VALUES (?,?)').run('schema', String(SCHEMA_VERSION)) db.prepare('INSERT INTO ticket (id, value) VALUES (?,?)').run('t', JSON.stringify(ticket('t', 'open', [{ state: 'running' }]))) db.close() const store = new Store(path) // The open set reads the ticket's end now. expect(ids(store.tickets.open())).toEqual(['t']) expect(store.db.prepare("SELECT name FROM pragma_table_xinfo('ticket') WHERE name = 'ref'").all()).toEqual([]) store.close() }) it('lists in the same order in SQLite and in memory, and undoes a failed nested transaction alone in both', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) const order = (items: { id?: string; key?: string }[]) => items.map(item => item.id ?? item.key) const decision = (id: string): Decision => ({ id, evalId: '', threadKey: 'github:1', triggerIds: [], policyVersion: '', at: id, kind: 'ignore', reason: [], strength: 'explicit' }) const run = (repo: Repositories) => { repo.tickets.save(ticket('first', 'open', [{ state: 'running' }])) repo.tickets.save(ticket('second', 'ended', [{ state: 'ended' }])) repo.tickets.save(ticket('third', 'open', [{ state: 'pending' }])) // Saving a ticket again keeps its place. repo.tickets.save({ ...repo.tickets.get('first')!, title: 'again' }) repo.threads.save(thread('1', 'github', { pending: ['a'] })); repo.threads.save(thread('2', 'github')); repo.threads.save(thread('3', 'github', { pending: ['b'] })) repo.decisions.save(decision('d2')) repo.decisions.save(decision('d1')) // A failed nested transaction undoes its own writes; the one around it goes on and keeps its own. repo.atomic(() => { repo.tickets.save(ticket('outer', 'open', [])) expect(() => repo.atomic(() => { repo.tickets.save(ticket('inner', 'open', [])); throw new Error('boom') })).toThrow('boom') }) return { tickets: order(repo.tickets.list()), open: order(repo.tickets.open()), active: order(repo.tickets.active()), threads: order(repo.threads.list()), activeThreads: order(repo.threads.active()), decisions: order(repo.decisions.byThread('github:1')) } } const memory = run(new MemoryRepositories()) expect(memory).toEqual({ tickets: ['outer', 'third', 'second', 'first'], open: ['outer', 'third', 'first'], active: ['third', 'first'], threads: ['github:1', 'github:2', 'github:3'], activeThreads: ['github:1', 'github:3'], decisions: ['d2', 'd1'] }) expect(run(store)).toEqual(memory) store.close() }) it('creates the state directory for its owner alone, and the database readable by its owner alone', () => { const path = join(directory, 'state', 'orchestrator.sqlite') new Store(path).close() expect([statSync(dirname(path)).mode & 0o777, statSync(path).mode & 0o777]).toEqual([0o700, 0o600]) }) it('reads `%` and `_` in a key prefix and a search query as written, not as patterns', () => { const store = new Store(join(directory, 'orchestrator.sqlite')) for (const name of ['a%b', 'axb', 'a_c', 'abc']) store.set(name, 1) expect([store.keys('a%').sort(), store.keys('a_').sort()]).toEqual([['a%b'], ['a_c']]) const said = (id: string, text: string) => ({ id, source: 'feishu', threads: [{ source: 'feishu', id: 'oc_1:om_1' }], actor: { source: 'feishu', id: 'u' }, human: true, at: '2026-10-01T00:00:00.000Z', text, meta: {} }) for (const [id, text] of [['p', 'grew 50% today'], ['q', 'grew 500 today'], ['r', 'see a_b'], ['s', 'see axb']]) store.triggers.save(said(id!, text!)) expect([store.read.search('50%').map(item => item.id), store.read.search('a_b').map(item => item.id)]).toEqual([['p'], ['r']]) store.close() })