mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
160 lines
7.1 KiB
TypeScript
160 lines
7.1 KiB
TypeScript
/**
|
||
* #2194 fix #3 / #2227 bug #3 — the cycle split.
|
||
*
|
||
* Per-source autopilot cycles run ONLY source-scoped (+ mixed) phases; the
|
||
* brain-wide `global` phases run ONCE in a separate autopilot-global-maintenance
|
||
* job. This replaces the rejected skip-and-stamp-fresh design (codex #1/#2): the
|
||
* split makes single-flight structural (one global job, not N concurrent embeds)
|
||
* and never marks a source "fresh" for global work it didn't do. These tests pin
|
||
* the phase partition, the dispatch gate, the per-source phase set, and the
|
||
* global handler stamping autopilot.last_global_at.
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||
import { mkdtempSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
|
||
import {
|
||
ALL_PHASES,
|
||
GLOBAL_PHASES,
|
||
NON_GLOBAL_PHASES,
|
||
PHASE_SCOPE,
|
||
LAST_GLOBAL_AT_KEY,
|
||
} from '../src/core/cycle.ts';
|
||
import {
|
||
dispatchGlobalMaintenance,
|
||
isGlobalMaintenanceStale,
|
||
dispatchPerSource,
|
||
} from '../src/commands/autopilot-fanout.ts';
|
||
import type { BrainEngine } from '../src/core/engine.ts';
|
||
|
||
describe('cycle phase partition (#2194 fix #3)', () => {
|
||
test('GLOBAL ∪ NON_GLOBAL == ALL_PHASES, no overlap', () => {
|
||
const union = new Set([...GLOBAL_PHASES, ...NON_GLOBAL_PHASES]);
|
||
expect(union.size).toBe(ALL_PHASES.length);
|
||
for (const p of ALL_PHASES) expect(union.has(p)).toBe(true);
|
||
// No phase in both.
|
||
const overlap = GLOBAL_PHASES.filter((p) => NON_GLOBAL_PHASES.includes(p));
|
||
expect(overlap).toEqual([]);
|
||
});
|
||
|
||
test('every GLOBAL phase is PHASE_SCOPE==="global"; embed is global, lint is not', () => {
|
||
for (const p of GLOBAL_PHASES) expect(PHASE_SCOPE[p]).toBe('global');
|
||
expect(GLOBAL_PHASES).toContain('embed');
|
||
expect(GLOBAL_PHASES).toContain('orphans');
|
||
expect(GLOBAL_PHASES).toContain('purge');
|
||
expect(NON_GLOBAL_PHASES).toContain('lint');
|
||
expect(NON_GLOBAL_PHASES).toContain('sync');
|
||
expect(NON_GLOBAL_PHASES).not.toContain('embed');
|
||
});
|
||
});
|
||
|
||
describe('isGlobalMaintenanceStale', () => {
|
||
const now = Date.UTC(2026, 5, 16, 12, 0, 0);
|
||
test('null/unparseable → stale (must run)', () => {
|
||
expect(isGlobalMaintenanceStale(null, now)).toBe(true);
|
||
expect(isGlobalMaintenanceStale('not-a-date', now)).toBe(true);
|
||
});
|
||
test('older than floor → stale; within floor → fresh', () => {
|
||
expect(isGlobalMaintenanceStale(new Date(now - 61 * 60_000).toISOString(), now, 60)).toBe(true);
|
||
expect(isGlobalMaintenanceStale(new Date(now - 10 * 60_000).toISOString(), now, 60)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('dispatchGlobalMaintenance — single-flight gate', () => {
|
||
function stubs(lastGlobalAt: string | null) {
|
||
const added: Array<{ name: string; data: any; opts: any }> = [];
|
||
const engine = {
|
||
kind: 'postgres' as const,
|
||
getConfig: async (k: string) => (k === LAST_GLOBAL_AT_KEY ? lastGlobalAt : null),
|
||
} as unknown as BrainEngine;
|
||
const queue = {
|
||
add: async (name: string, data: unknown, opts: Record<string, unknown>) => {
|
||
added.push({ name, data, opts }); return { id: 1 };
|
||
},
|
||
} as any;
|
||
return { engine, queue, added };
|
||
}
|
||
|
||
test('stale (never run) → dispatches one global job with single-flight opts', async () => {
|
||
const { engine, queue, added } = stubs(null);
|
||
const r = await dispatchGlobalMaintenance(engine, queue, { repoPath: '/tmp', slot: 's1', timeoutMs: 1, jsonMode: true, emit: () => {} });
|
||
expect(r.dispatched).toBe(true);
|
||
expect(added.length).toBe(1);
|
||
expect(added[0].name).toBe('autopilot-global-maintenance');
|
||
expect(added[0].opts.idempotency_key).toBe('autopilot-global:s1');
|
||
expect(added[0].opts.maxWaiting).toBe(1); // structural single-flight
|
||
expect(added[0].data.phases).toEqual(GLOBAL_PHASES);
|
||
});
|
||
|
||
test('fresh → does NOT dispatch', async () => {
|
||
const { engine, queue, added } = stubs(new Date().toISOString());
|
||
const r = await dispatchGlobalMaintenance(engine, queue, { repoPath: '/tmp', slot: 's1', timeoutMs: 1, jsonMode: true, emit: () => {} });
|
||
expect(r.dispatched).toBe(false);
|
||
expect(added.length).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('dispatchPerSource — per-source jobs carry NON_GLOBAL phases (no embed)', () => {
|
||
test('each per-source job sets phases = NON_GLOBAL_PHASES', async () => {
|
||
const sources = [{ id: 'repo-a', name: 'a', config: {} }, { id: 'repo-b', name: 'b', config: {} }];
|
||
const added: any[] = [];
|
||
const engine = {
|
||
kind: 'postgres' as const,
|
||
listAllSources: async () => sources,
|
||
getConfig: async () => null,
|
||
executeRaw: async () => [],
|
||
} as unknown as BrainEngine;
|
||
const queue = { add: async (name: string, data: unknown, opts: unknown) => { added.push({ name, data, opts }); return { id: added.length }; } } as any;
|
||
await dispatchPerSource(engine, queue, { repoPath: '/tmp', slot: 's', timeoutMs: 1, fanoutMax: 4, jsonMode: true, emit: () => {}, log: () => {} });
|
||
expect(added.length).toBe(2);
|
||
for (const j of added) {
|
||
expect(j.data.phases).toEqual(NON_GLOBAL_PHASES);
|
||
expect(j.data.phases).not.toContain('embed');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('autopilot-global-maintenance handler stamps last_global_at (PGLite)', () => {
|
||
let engine: PGLiteEngine;
|
||
beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); }, 30000);
|
||
afterAll(async () => { await engine.disconnect(); });
|
||
beforeEach(async () => { await resetPgliteState(engine); });
|
||
|
||
async function captureHandlers() {
|
||
const handlers = new Map<string, (job: any) => Promise<any>>();
|
||
const fakeWorker = { register(name: string, fn: (job: any) => Promise<any>) { handlers.set(name, fn); } };
|
||
await registerBuiltinHandlers(fakeWorker as never, engine);
|
||
return handlers;
|
||
}
|
||
|
||
test('runs global phases (no source_id) and stamps autopilot.last_global_at on success', async () => {
|
||
expect(await engine.getConfig(LAST_GLOBAL_AT_KEY)).toBeNull();
|
||
const repoPath = mkdtempSync(join(tmpdir(), 'gbrain-global-maintenance-'));
|
||
await engine.executeRaw(
|
||
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
|
||
['repo-a', 'repo-a', repoPath],
|
||
);
|
||
const handlers = await captureHandlers();
|
||
const handler = handlers.get('autopilot-global-maintenance');
|
||
expect(handler).toBeTruthy();
|
||
|
||
const result = await handler!({
|
||
data: { phases: ['orphans', 'embed'], repoPath },
|
||
signal: undefined,
|
||
});
|
||
// The cycle ran the requested global phases (DB-only on an empty brain).
|
||
const orphans = result.report.phases.find((p: any) => p.phase === 'orphans');
|
||
expect(orphans).toBeTruthy();
|
||
expect(orphans.details.source_id).toBeUndefined();
|
||
expect(['ok', 'clean', 'partial']).toContain(result.report.status);
|
||
// Freshness stamped so the dispatch gate backs off.
|
||
const stamped = await engine.getConfig(LAST_GLOBAL_AT_KEY);
|
||
expect(stamped).not.toBeNull();
|
||
expect(Number.isFinite(new Date(stamped!).getTime())).toBe(true);
|
||
});
|
||
});
|