Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ecs/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export class Engine {
this.eventBus.on(eventType, handler);
}

off(eventType: string, handler: (event: ECSEvent) => void | Promise<void>): void {
this.eventBus.off(eventType, handler);
}

emit(event: ECSEvent): void {
this.store.logEvent(event);
this.eventBus.emit(event);
Expand Down
7 changes: 7 additions & 0 deletions src/systems/judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export default function createJudge(engine: Engine): System {
parsed = JudgementSchema.safeParse(JSON.parse(response.content));
} catch {
engine.getLogger().warn({ content: response.content }, 'TheJudge: failed to parse response');
engine.emit({
type: 'system:error',
entityId: event.entityId,
data: { system: 'TheJudge', error: 'Failed to parse LLM response as JSON' },
source: 'TheJudge',
timestamp: Date.now(),
});
return;
}

Expand Down
8 changes: 6 additions & 2 deletions src/systems/recursive-improver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,12 @@ async function runNextIteration(
// Route the experiment for execution — the experiment goes through the normal
// system-selector → execution → judge pipeline. We listen for the judgement
// back via task:judged on this entity, then evaluate it in the loop context.
engine.on('task:judged', async function onJudged(judgedEvent) {
const onJudged = async (judgedEvent: import('../ecs/types.js').ECSEvent) => {
if (judgedEvent.entityId !== experimentId) return;

// Unregister this one-shot listener
engine.off('task:judged', onJudged);

// Evaluate the experiment result in the context of the improvement loop
const judgement = engine.getComponent(experimentId, 'Judgement');
const proposal = engine.getComponent(experimentId, 'ExperimentProposal');
Expand Down Expand Up @@ -353,7 +356,8 @@ async function runNextIteration(
source: 'RecursiveImprover',
timestamp: Date.now(),
});
});
};
engine.on('task:judged', onJudged);

// Route the experiment through the standard pipeline
engine.emit({
Expand Down
9 changes: 9 additions & 0 deletions src/systems/system-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export default function createSystemSelector(engine: Engine): System {
source: 'SystemSelector',
timestamp: Date.now(),
});
} else {
engine.getLogger().warn({ entityId: event.entityId }, 'SystemSelector: no system selected by LLM');
engine.emit({
type: 'system:error',
entityId: event.entityId,
data: { system: 'SystemSelector', error: 'LLM did not select a system' },
source: 'SystemSelector',
timestamp: Date.now(),
});
}
},
};
Expand Down
7 changes: 7 additions & 0 deletions src/systems/task-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export default function createTaskGenerator(engine: Engine): System {
parsed = TaskListSchema.safeParse(JSON.parse(response.content));
} catch {
engine.getLogger().warn({ content: response.content }, 'TaskGenerator: failed to parse response');
engine.emit({
type: 'system:error',
entityId: event.entityId,
data: { system: 'TaskGenerator', error: 'Failed to parse LLM response as JSON' },
source: 'TaskGenerator',
timestamp: Date.now(),
});
return;
}

Expand Down
62 changes: 62 additions & 0 deletions tests/container/ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { IPC } from '../../src/container/ipc.js';

describe('IPC', () => {
let ipcDir: string;

beforeEach(() => {
ipcDir = join(tmpdir(), `chippr-test-ipc-${Date.now()}`);
});

afterEach(() => {
if (existsSync(ipcDir)) {
rmSync(ipcDir, { recursive: true, force: true });
}
});

it('creates IPC directory if not exists', () => {
expect(existsSync(ipcDir)).toBe(false);
new IPC(ipcDir);
expect(existsSync(ipcDir)).toBe(true);
});

it('writes and reads request files', () => {
const ipc = new IPC(ipcDir);
ipc.sendRequest('req-1', { action: 'run', args: ['hello'] });

const data = ipc.readRequest('req-1');
expect(data).toEqual({ action: 'run', args: ['hello'] });
});

it('writes and reads response files', () => {
const ipc = new IPC(ipcDir);
ipc.writeResponse('resp-1', { status: 'ok', result: 42 });

const data = ipc.readResponse('resp-1');
expect(data).toEqual({ status: 'ok', result: 42 });
});

it('returns null for missing response', () => {
const ipc = new IPC(ipcDir);
expect(ipc.readResponse('nonexistent')).toBeNull();
});

it('returns null for missing request', () => {
const ipc = new IPC(ipcDir);
expect(ipc.readRequest('nonexistent')).toBeNull();
});

it('round-trips complex JSON data', () => {
const ipc = new IPC(ipcDir);
const complex = {
nested: { deeply: { value: [1, 2, 3] } },
unicode: '日本語',
special: 'hello\nworld\ttab',
};
ipc.sendRequest('complex', complex);
expect(ipc.readRequest('complex')).toEqual(complex);
});
});
117 changes: 117 additions & 0 deletions tests/container/runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { runInContainer, type ContainerConfig } from '../../src/container/runner.js';
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';

vi.mock('node:child_process', () => {
return {
spawn: vi.fn(),
};
});

import { spawn } from 'node:child_process';

function mockProcess(stdout = '', stderr = '', exitCode = 0): ChildProcess {
const proc = new EventEmitter() as ChildProcess;
const stdoutEmitter = new EventEmitter();
const stderrEmitter = new EventEmitter();
(proc as any).stdout = stdoutEmitter;
(proc as any).stderr = stderrEmitter;

// Schedule data emission and close
setTimeout(() => {
if (stdout) stdoutEmitter.emit('data', Buffer.from(stdout));
if (stderr) stderrEmitter.emit('data', Buffer.from(stderr));
proc.emit('close', exitCode);
}, 0);

return proc;
}

describe('runInContainer', () => {
const baseConfig: ContainerConfig = {
runtime: 'docker',
image: 'test-image:latest',
mountPaths: [],
};

beforeEach(() => {
vi.mocked(spawn).mockReset();
});

it('spawns docker with correct args', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess('hello'));
const result = await runInContainer(baseConfig, ['echo', 'hello']);

expect(spawn).toHaveBeenCalledWith(
'docker',
['run', '--rm', '--network', 'none', 'test-image:latest', 'echo', 'hello'],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(result.stdout).toBe('hello');
expect(result.exitCode).toBe(0);
});

it('spawns apple-container with container binary', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess());
await runInContainer({ ...baseConfig, runtime: 'apple-container' }, ['ls']);

expect(spawn).toHaveBeenCalledWith(
'container',
expect.any(Array),
expect.any(Object),
);
});

it('handles read-only and writable mounts', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess());
await runInContainer(
{ ...baseConfig, mountPaths: ['/data'], writablePaths: ['/output'] },
['run'],
);

const args = vi.mocked(spawn).mock.calls[0][1];
expect(args).toContain('-v');
expect(args).toContain('/data:/data:ro');
expect(args).toContain('/output:/output:rw');
});

it('passes environment variables', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess());
await runInContainer(
{ ...baseConfig, env: { FOO: 'bar', BAZ: 'qux' } },
['test'],
);

const args = vi.mocked(spawn).mock.calls[0][1];
expect(args).toContain('-e');
expect(args).toContain('FOO=bar');
expect(args).toContain('BAZ=qux');
});

it('returns stdout, stderr, exitCode', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess('out', 'err', 1));
const result = await runInContainer(baseConfig, ['fail']);
expect(result).toEqual({ stdout: 'out', stderr: 'err', exitCode: 1 });
});

it('rejects on spawn error', async () => {
const proc = new EventEmitter() as ChildProcess;
(proc as any).stdout = new EventEmitter();
(proc as any).stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(proc);

const promise = runInContainer(baseConfig, ['bad']);
setTimeout(() => proc.emit('error', new Error('spawn ENOENT')), 0);

await expect(promise).rejects.toThrow('spawn ENOENT');
});

it('enables network when networkEnabled is true', async () => {
vi.mocked(spawn).mockReturnValue(mockProcess());
await runInContainer({ ...baseConfig, networkEnabled: true }, ['test']);

const args = vi.mocked(spawn).mock.calls[0][1];
expect(args).not.toContain('--network');
});
});
105 changes: 105 additions & 0 deletions tests/e2e/ingest-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync, existsSync } from 'node:fs';
import { Store } from '../../src/store/db.js';
import { Engine } from '../../src/ecs/engine.js';
import { IngestPipeline } from '../../src/ingest/pipeline.js';
import { mockLogger, mockProvider, collectEvents } from '../helpers.js';

describe('Ingest Pipeline E2E', () => {
let store: Store;
let engine: Engine;
let pipeline: IngestPipeline;
let uploadDir: string;

beforeEach(() => {
store = new Store(':memory:');
const logger = mockLogger();
const provider = mockProvider([]);
engine = new Engine(store, provider, logger);
uploadDir = join(tmpdir(), `chippr-e2e-ingest-${Date.now()}`);
pipeline = new IngestPipeline(engine, logger as any, uploadDir);
});

afterEach(() => {
store.close();
if (existsSync(uploadDir)) {
rmSync(uploadDir, { recursive: true, force: true });
}
});

it('ingests a text file and creates entity with components', async () => {
const events = collectEvents(engine);

const result = await pipeline.ingest({
filename: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Hello, world!'),
source: 'test',
});

expect(result.mediaId).toBeDefined();
expect(result.entityId).toBeDefined();
expect(result.category).toBe('text');
expect(result.extractedText).toContain('Hello, world!');

// Verify entity exists
expect(engine.entityExists(result.entityId)).toBe(true);

// Verify events were emitted
expect(events.some((e) => e.type === 'media:ingested')).toBe(true);
});

it('stores memory record for ingested file', async () => {
const result = await pipeline.ingest({
filename: 'doc.txt',
mimeType: 'text/plain',
buffer: Buffer.from('Important document content'),
source: 'test',
contextId: 'ctx-1',
});

// Memory should be stored with context
const memory = store.getMemory('ctx-1');
expect(memory.length).toBeGreaterThan(0);
});

it('batch upload creates multiple entities', async () => {
const results = await pipeline.ingestBatch([
{
filename: 'file1.txt',
mimeType: 'text/plain',
buffer: Buffer.from('File 1 content'),
source: 'test',
},
{
filename: 'file2.txt',
mimeType: 'text/plain',
buffer: Buffer.from('File 2 content'),
source: 'test',
},
]);

expect(results.length).toBe(2);
expect(engine.entityExists(results[0].entityId)).toBe(true);
expect(engine.entityExists(results[1].entityId)).toBe(true);

// Verify they're different entities
expect(results[0].entityId).not.toBe(results[1].entityId);
});

it('emits entity:needs-routing when message is included', async () => {
const events = collectEvents(engine, 'entity:needs-routing');

await pipeline.ingest({
filename: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('content'),
source: 'test',
message: 'Analyze this file',
});

expect(events.some((e) => e.type === 'entity:needs-routing')).toBe(true);
});
});
Loading
Loading