Skip to content
Merged
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
109 changes: 106 additions & 3 deletions packages/factory/src/server/services/__tests__/project-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { homedir } from 'node:os';
import { join } from 'node:path';

import { RunDataParseError } from '@codeassembly/run-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { CanonicalRunStatus } from '../../../shared/types/canonical.js';
Expand Down Expand Up @@ -212,8 +213,64 @@ describe('ProjectScanner', () => {
});

// Error recovery: skip-and-continue — invalid runs are skipped, valid siblings still collected
it('skips runs with invalid status.json and continues scanning other runs', async () => {
using _silent = silencedConsole();
it.each([
{
category: 'invalid_schema' as const,
message: 'Invalid run-index.json at /test/path',
expectedSuggestion: 'incompatible version',
},
{
category: 'corrupt_json' as const,
message: 'Failed to parse JSON at /test/path',
expectedSuggestion: 'Check for syntax errors',
},
{
category: 'missing_companion' as const,
message: 'v3 run-index.json found but run-log.jsonl is missing',
expectedSuggestion: 'run may have been interrupted',
},
])(
'logs warning with suggestion for $category errors and skips the run',
async ({ category, message, expectedSuggestion }) => {
using silent = silencedConsole();
const scanner = new ProjectScanner('/test/projects');

mockReaddirResult(['proj']);
mockStatDirectory();
mockReaddirResult(['tickets']);
mockReaddirResult(['TICKET-1']);
mockStatDirectory(); // stat for TICKET-1 directory
mockReaddirResult(['bad-run', 'good-run']);
mockStatDirectory(); // stat for bad-run directory
mockStatDirectory(); // stat for good-run directory

mockedParseRunData.mockRejectedValueOnce(new RunDataParseError(message, category, '/test/path'));
mockedParseRunData.mockResolvedValueOnce(
createMockStatus({
runId: 'good-run',
startedAt: '2026-03-01T00:00:00Z',
}),
);

const result = await scanner.scan();

expect(result.projects).toHaveLength(1);
const runs = result.projects[0]?.tickets[0]?.runs;
expect(runs).toHaveLength(1);
expect(runs?.[0]?.runId).toBe('good-run');

expect(silent.warn).toHaveBeenCalledOnce();
expect(silent.warn).toHaveBeenCalledWith(
expect.stringContaining('[project-scanner] Skipping proj/TICKET-1/bad-run:'),
);
expect(silent.warn).toHaveBeenCalledWith(expect.stringContaining(message));
expect(silent.warn).toHaveBeenCalledWith(expect.stringContaining(expectedSuggestion));
expect(silent.error).not.toHaveBeenCalled();
},
);

it('logs error for non-RunDataParseError exceptions during run parsing', async () => {
using silent = silencedConsole();
const scanner = new ProjectScanner('/test/projects');

mockReaddirResult(['proj']);
Expand All @@ -225,7 +282,7 @@ describe('ProjectScanner', () => {
mockStatDirectory(); // stat for bad-run directory
mockStatDirectory(); // stat for good-run directory

mockedParseRunData.mockRejectedValueOnce(new Error('Invalid JSON'));
mockedParseRunData.mockRejectedValueOnce(new Error('Permission denied'));
mockedParseRunData.mockResolvedValueOnce(
createMockStatus({
runId: 'good-run',
Expand All @@ -239,6 +296,52 @@ describe('ProjectScanner', () => {
const runs = result.projects[0]?.tickets[0]?.runs;
expect(runs).toHaveLength(1);
expect(runs?.[0]?.runId).toBe('good-run');

expect(silent.error).toHaveBeenCalledOnce();
expect(silent.error).toHaveBeenCalledWith(
expect.stringContaining('Error parsing run data for proj/TICKET-1/bad-run:'),
expect.any(Error),
);
expect(silent.warn).not.toHaveBeenCalled();
});

// Error recovery: ENOENT from run directories with no recognized log files
it('logs warning and skips run directories where neither run-index.json nor status.json exists', async () => {
using silent = silencedConsole();
const scanner = new ProjectScanner('/test/projects');

mockReaddirResult(['proj']);
mockStatDirectory();
mockReaddirResult(['tickets']);
mockReaddirResult(['TICKET-1']);
mockStatDirectory(); // stat for TICKET-1 directory
mockReaddirResult(['empty-run', 'good-run']);
mockStatDirectory(); // stat for empty-run directory
mockStatDirectory(); // stat for good-run directory

const enoentError = new Error('ENOENT: no such file or directory');
Object.assign(enoentError, { code: 'ENOENT' });
mockedParseRunData.mockRejectedValueOnce(enoentError);
mockedParseRunData.mockResolvedValueOnce(
createMockStatus({
runId: 'good-run',
startedAt: '2026-03-01T00:00:00Z',
}),
);

const result = await scanner.scan();

expect(result.projects).toHaveLength(1);
const runs = result.projects[0]?.tickets[0]?.runs;
expect(runs).toHaveLength(1);
expect(runs?.[0]?.runId).toBe('good-run');

expect(silent.warn).toHaveBeenCalledOnce();
expect(silent.warn).toHaveBeenCalledWith(
expect.stringContaining('[project-scanner] Skipping proj/TICKET-1/empty-run:'),
);
expect(silent.warn).toHaveBeenCalledWith(expect.stringContaining('no run-index.json or status.json found'));
expect(silent.error).not.toHaveBeenCalled();
});

it('does not scan direct entries when tickets/ directory exists', async () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/factory/src/server/services/project-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';

import type { RunDataParseErrorCategory } from '@codeassembly/run-core';
import { RunDataParseError } from '@codeassembly/run-core';

import type { ProjectIndex, ProjectInfo, RunInfo, TicketInfo } from '../../shared/types/api.js';
import { parseRunData } from '../adapters/status-adapter.js';
import { isEnoent } from '../type-guards.js';

const PARSE_ERROR_SUGGESTIONS: Record<RunDataParseErrorCategory, string> = {
corrupt_json: 'Check for syntax errors or delete and regenerate the file',
invalid_schema: 'The file may be from an incompatible version; delete the run directory to clear it',
missing_companion: 'The run may have been interrupted; delete the run directory to clear it',
};

export class ProjectScanner {
private basePath: string;
private index: ProjectIndex | null = null;
Expand Down Expand Up @@ -138,8 +147,17 @@ export class ProjectScanner {
});
} catch (error) {
if (isEnoent(error)) {
console.warn(
`[project-scanner] Skipping ${slug}/${ticketId}/${runId}: no run-index.json or status.json found — the run directory may be empty or incomplete; delete it to clear this warning`,
);
continue;
}
if (error instanceof RunDataParseError) {
const suggestion = PARSE_ERROR_SUGGESTIONS[error.category];
console.warn(`[project-scanner] Skipping ${slug}/${ticketId}/${runId}: ${error.message} — ${suggestion}`);
continue;
}
// Non-parse errors are truly unexpected (permissions, I/O failures)
console.error(`Error parsing run data for ${slug}/${ticketId}/${runId}:`, error);
continue;
}
Expand Down
55 changes: 55 additions & 0 deletions packages/run-core/src/__tests__/run-data-parse-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import type { core } from 'zod';

import type { RunDataParseErrorCategory } from '../run-data-parse-error.js';
import { RunDataParseError } from '../run-data-parse-error.js';

describe('RunDataParseError', () => {
it('extends Error with the correct name', () => {
const error = new RunDataParseError('test', 'corrupt_json', '/path/to/file.json');

expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('RunDataParseError');
});

it('preserves the message', () => {
const error = new RunDataParseError(
'Failed to parse JSON at /path/to/file.json',
'corrupt_json',
'/path/to/file.json',
);

expect(error.message).toBe('Failed to parse JSON at /path/to/file.json');
});

it('stores category, filePath, and zodIssues fields', () => {
const issues: core.$ZodIssue[] = [
{ code: 'invalid_type', expected: 'string', path: ['runId'], message: 'Expected string' },
];
const error = new RunDataParseError(
'Invalid run-index.json at /path',
'invalid_schema',
'/path/run-index.json',
issues,
);

expect(error.category).toBe('invalid_schema');
expect(error.filePath).toBe('/path/run-index.json');
expect(error.zodIssues).toBe(issues);
});

it('has undefined zodIssues when not provided', () => {
const error = new RunDataParseError('test', 'missing_companion', '/path/to/file.json');

expect(error.zodIssues).toBeUndefined();
});

it.each<RunDataParseErrorCategory>(['corrupt_json', 'invalid_schema', 'missing_companion'])(
'accepts category "%s"',
(category) => {
const error = new RunDataParseError('test', category, '/path');

expect(error.category).toBe(category);
},
);
});
4 changes: 4 additions & 0 deletions packages/run-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export { parseRunLogLine, runEventSchema, v3RunIndexSchema } from './schemas/run
// Schemas — status.json (v1)
export { v1StatusSchema } from './schemas/status-json-schema.js';

// Errors — structured parse error class
export type { RunDataParseErrorCategory } from './run-data-parse-error.js';
export { RunDataParseError } from './run-data-parse-error.js';

// Event folder — reconstruct CanonicalRunStatus from header + events
export { foldEvents } from './event-folder.js';

Expand Down
115 changes: 115 additions & 0 deletions packages/run-core/src/parsers/__tests__/run-data-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { RunDataParseError } from '../../run-data-parse-error.js';
import { parseRunData, parseStatusFile } from '../run-data-parser.js';

const { mockedReadFile } = vi.hoisted(() => ({
Expand Down Expand Up @@ -596,6 +597,34 @@ describe('parseStatusFile', () => {
await expect(parseStatusFile('/path/to/status.json')).rejects.toThrow('Invalid status.json');
});
});

describe('RunDataParseError metadata', () => {
it('throws RunDataParseError with corrupt_json category for invalid JSON', async () => {
mockedReadFile.mockResolvedValue('not json');

const error = await parseStatusFile('/path/to/status.json').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
expect(error).toBeInstanceOf(Error);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('corrupt_json');
expect(error.filePath).toBe('/path/to/status.json');
expect(error.zodIssues).toBeUndefined();
});

it('throws RunDataParseError with invalid_schema category and zodIssues for schema failures', async () => {
mockedReadFile.mockResolvedValue('{}');

const error = await parseStatusFile('/path/to/status.json').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('invalid_schema');
expect(error.filePath).toBe('/path/to/status.json');
expect(error.zodIssues).toBeDefined();
expect(error.zodIssues?.length).toBeGreaterThan(0);
});
});
});

describe('parseRunData', () => {
Expand Down Expand Up @@ -1116,4 +1145,90 @@ describe('parseRunData', () => {
);
});
});

describe('RunDataParseError metadata', () => {
it('throws RunDataParseError with corrupt_json for invalid run-index.json JSON', async () => {
mockFileContents({
'/runs/test-run/run-index.json': '{ not valid json !!!',
});

const error = await parseRunData('/runs/test-run').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('corrupt_json');
expect(error.filePath).toBe('/runs/test-run/run-index.json');
});

it('throws RunDataParseError with invalid_schema for invalid v2 run-index.json', async () => {
const invalid = { ...minimalV2(), version: 1 };
mockFileContents({
'/runs/test-run/run-index.json': JSON.stringify(invalid),
});

const error = await parseRunData('/runs/test-run').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('invalid_schema');
expect(error.filePath).toBe('/runs/test-run/run-index.json');
expect(error.zodIssues).toBeDefined();
expect(error.zodIssues?.length).toBeGreaterThan(0);
});

it('throws RunDataParseError with missing_companion when v3 run-log.jsonl is absent', async () => {
const v3Header = {
version: 3,
context: {
runId: 'v3-test-run',
projectSlug: 'test',
projectRoot: '/test',
branch: 'main',
task: 'test task',
startedAt: '2026-01-01T00:00:00Z',
},
config: {
mode: 'orchestrated',
model: 'claude-opus-4-6',
},
};
mockFileContents({
'/runs/test-run/run-index.json': JSON.stringify(v3Header),
});

const error = await parseRunData('/runs/test-run').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('missing_companion');
expect(error.filePath).toBe('/runs/test-run/run-index.json');
});

it('throws RunDataParseError with corrupt_json for invalid status.json JSON (v1 fallback)', async () => {
mockFileContents({
'/runs/test-run/status.json': 'not json at all',
});

const error = await parseRunData('/runs/test-run').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('corrupt_json');
expect(error.filePath).toBe('/runs/test-run/status.json');
});

it('throws RunDataParseError with invalid_schema for invalid status.json schema (v1 fallback)', async () => {
mockFileContents({
'/runs/test-run/status.json': JSON.stringify({ runId: 'only-one-field' }),
});

const error = await parseRunData('/runs/test-run').catch((error_: unknown) => error_);

expect(error).toBeInstanceOf(RunDataParseError);
if (!(error instanceof RunDataParseError)) return;
expect(error.category).toBe('invalid_schema');
expect(error.filePath).toBe('/runs/test-run/status.json');
expect(error.zodIssues).toBeDefined();
});
});
});
Loading