SPB Git

Repository Public

worker.test.ts 6.8 KB 140 lines typescript

1/** The worker process end to end: fail-fast configuration, start-up, a mission without its provider key, graceful shutdown. */2import { execFileSync, spawn, type ChildProcess } from 'node:child_process';3import { randomBytes } from 'node:crypto';4import fs from 'node:fs';5import os from 'node:os';6import path from 'node:path';7import { fileURLToPath } from 'node:url';8import { createKernel, createMission, type Kernel } from '@aos/kernel';9import { afterAll, describe, expect, it } from 'vitest';10import { ConfigError, loadConfig } from '../src/config.js';1112const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main.ts');13const FAKE_KEY = 'sk-ant-test-0000-not-a-real-key';14const children = new Set<ChildProcess>();15const cleanups: (() => Promise<void> | void)[] = [];16afterAll(async () => {17  for (const c of children) c.kill('SIGKILL');18  for (const fn of cleanups.reverse()) await fn();19});2021interface Proc {22  child: ChildProcess;23  out: () => string;24  exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;25}26function runMain(env: Record<string, string>): Proc {27  // A clean environment: the developer's own keys must not leak into the test.28  const base = { PATH: process.env.PATH ?? '', HOME: os.homedir(), USER: os.userInfo().username, LANG: 'C' };29  const child = spawn(process.execPath, ['--import', 'tsx', MAIN], { env: { ...base, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });30  children.add(child);31  let buf = '';32  child.stdout?.on('data', (d: Buffer) => (buf += d.toString()));33  child.stderr?.on('data', (d: Buffer) => (buf += d.toString()));34  const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => child.once('exit', (code, signal) => {35    children.delete(child);36    resolve({ code, signal });37  }));38  return { child, out: () => buf, exit };39}40async function waitFor<T>(fn: () => Promise<T | null | undefined | false>, what: string, timeoutMs = 30_000): Promise<T> {41  const end = Date.now() + timeoutMs;42  for (;;) {43    const v = await fn();44    if (v) return v;45    if (Date.now() > end) throw new Error(`timeout waiting for ${what}`);46    await new Promise((r) => setTimeout(r, 50));47  }48}4950describe('configuration', () => {51  const ok = { DATABASE_URL: 'postgres:///x', AOS_DATA_DIR: '/tmp/aos-data', ANTHROPIC_API_KEY: FAKE_KEY };52  it('parses a valid environment', () => {53    const c = loadConfig({ ...ok, AOS_MAX_CONCURRENT_RUNS: '2', AOS_WORKER_ID: 'w1', AOS_WORKSPACE_ROOT: '/srv/ws' });54    expect(c).toMatchObject({ concurrency: 2, workerId: 'w1', workspaceRoot: '/srv/ws', providers: { anthropic: true, openai: false }, logLevel: 'info' });55    expect(JSON.stringify(c)).not.toContain(FAKE_KEY);56    expect(loadConfig(ok).concurrency).toBe(4);57  });58  it('refuses to start without a provider key, a database or a data dir — one clear message', () => {59    const err = (() => {60      try {61        loadConfig({ AOS_MAX_CONCURRENT_RUNS: 'many', ANTHROPIC_API_KEY: '  ' });62      } catch (e) {63        return e;64      }65    })();66    expect(err).toBeInstanceOf(ConfigError);67    const msg = (err as Error).message;68    expect(msg).toMatch(/DATABASE_URL is not set/);69    expect(msg).toMatch(/AOS_DATA_DIR is not set/);70    expect(msg).toMatch(/No model provider key is configured: set ANTHROPIC_API_KEY and\/or OPENAI_API_KEY/);71    expect(msg).toMatch(/AOS_MAX_CONCURRENT_RUNS must be an integer/);72  });73});7475describe('worker process', () => {76  it('exits 1 at once, with the reason on stderr, when no provider key is configured', async () => {77    const p = runMain({ DATABASE_URL: 'postgres:///aos_nonexistent?host=/tmp', AOS_DATA_DIR: '/tmp/aos-data-unused' });78    const { code } = await p.exit;79    expect(code).toBe(1);80    expect(p.out()).toMatch(/Agent OS worker cannot start:\n\s+- No model provider key is configured/);81  });8283  it('migrates, heartbeats, blocks a mission whose provider key is missing, and stops gracefully on SIGTERM', async () => {84    const name = `aos_test_${randomBytes(6).toString('hex')}`;85    execFileSync('createdb', [name], { stdio: 'pipe' });86    const url = `postgres:///${name}?host=/tmp`;87    const tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'aos-worker-test-'));88    let k: Kernel | null = null;89    cleanups.push(async () => {90      await k?.close().catch(() => undefined);91      try {92        execFileSync('dropdb', ['--force', '--if-exists', name], { stdio: 'pipe' });93      } catch {94        /* best effort */95      }96      fs.rmSync(tmp, { recursive: true, force: true });97    });98    const ws = path.join(tmp, 'root', 'p1');99    fs.mkdirSync(ws, { recursive: true });100101    const p = runMain({102      DATABASE_URL: url, AOS_DATA_DIR: path.join(tmp, 'data'), AOS_WORKSPACE_ROOT: path.join(tmp, 'root'), ANTHROPIC_API_KEY: FAKE_KEY,103      AOS_WORKER_ID: 'test-worker', AOS_MAX_CONCURRENT_RUNS: '2', AOS_LOG_LEVEL: 'info',104    });105    k = createKernel({ databaseUrl: url, dataDir: path.join(tmp, 'data'), poolMax: 3 });106    const kernel = k;107    // the worker applies the migrations itself, seeds prices and registers its heartbeat108    await waitFor(async () => {109      try {110        return (await kernel.pool.query(`SELECT id FROM workers WHERE id = 'test-worker'`)).rows.length === 1;111      } catch {112        return false;113      }114    }, 'the worker heartbeat');115    expect(Number((await kernel.pool.query('SELECT count(*) AS n FROM model_prices')).rows[0].n)).toBeGreaterThan(0);116117    // OpenAI key absent: the mission ends blocked, the worker stays up118    const owner = (await kernel.pool.query(`INSERT INTO users (username, password_hash) VALUES ('owner', 'x') RETURNING id`)).rows[0].id as string;119    const { rootRunId } = await createMission({ ownerId: owner, objective: 'Say hello', doneCriteria: ['hello said'], provider: 'openai', model: 'gpt-6-luna', budgetUsd: 0.05, workspaceAbs: ws }, kernel);120    const run = await waitFor(async () => {121      const r = (await kernel.pool.query('SELECT status, result FROM runs WHERE id = $1', [rootRunId])).rows[0] as { status: string; result: { summary: string } | null };122      return r.status === 'blocked' || r.status === 'failed' ? r : null;123    }, 'the mission to end');124    expect(run.status).toBe('blocked');125    expect(run.result?.summary).toMatch(/OPENAI_API_KEY\) is not configured on the worker/);126    expect(p.child.exitCode).toBeNull();127128    p.child.kill('SIGTERM');129    const { code, signal } = await p.exit;130    expect({ code, signal }).toEqual({ code: 0, signal: null });131    const out = p.out();132    expect(out).toMatch(/"msg":"worker starting"/);133    expect(out).toMatch(/"msg":"worker stopped"/);134    expect(out).toMatch(/provider key missing/);135    expect(out).not.toContain(FAKE_KEY); // never a key in the logs136    expect(out).not.toContain('Say hello'); // nor the mission text137    expect(out).not.toContain(name); // nor the connection string138  });139});140
Files main
Menu

Theme