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
1 change: 1 addition & 0 deletions packages/factory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"ws": "node --import tsx ../../scripts/run-workspace-script.ts"
},
"dependencies": {
"@codeassembly/run-core": "workspace:*",
"cors": "2.8.6",
"excalibur": "0.32.0",
"express": "5.2.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,12 @@ describe('parseStatusFile', () => {
});

describe('error handling', () => {
it('throws on invalid JSON', async () => {
it('throws on invalid JSON with file path context', async () => {
mockedReadFile.mockResolvedValue('not json');

await expect(parseStatusFile('/path/to/status.json')).rejects.toThrow();
await expect(parseStatusFile('/path/to/status.json')).rejects.toThrow(
/Failed to parse JSON at \/path\/to\/status\.json/,
);
});

it('throws on file read error (ENOENT)', async () => {
Expand Down Expand Up @@ -902,7 +904,9 @@ describe('parseRunData', () => {
});
mockedReadFile.mockClear();

await expect(parseRunData('/runs/test-run')).rejects.toThrow();
await expect(parseRunData('/runs/test-run')).rejects.toThrow(
/Failed to parse JSON at \/runs\/test-run\/run-index\.json/,
);
expect(mockedReadFile).toHaveBeenCalledTimes(1);
expect(mockedReadFile).toHaveBeenCalledWith('/runs/test-run/run-index.json', 'utf8');
});
Expand Down Expand Up @@ -960,14 +964,14 @@ describe('parseRunData', () => {
expect(result.model).toBe('claude-opus-4-6');
});

it('falls back to v2 parse when v3 header is present but run-log.jsonl is ENOENT', async () => {
// A v3 header without a run-log.jsonl causes the adapter to fall back to parseRunIndexFromRaw.
// A v3 header fails the v2 schema (version 3 !== 2), so this should throw.
it('throws when v3 header is present but run-log.jsonl is ENOENT', async () => {
mockFileContents({
'/runs/test-run/run-index.json': JSON.stringify(minimalV3Header()),
});

await expect(parseRunData('/runs/test-run')).rejects.toThrow('Invalid run-index.json');
await expect(parseRunData('/runs/test-run')).rejects.toThrow(
'v3 run-index.json found at /runs/test-run/run-index.json but run-log.jsonl is missing',
);
});

it('handles empty log file (returns initial state)', async () => {
Expand Down Expand Up @@ -1000,5 +1004,32 @@ describe('parseRunData', () => {

expect(result.status).toBe('completed');
});

it('skips corrupt JSON line mid-stream and processes surrounding events', async () => {
const lines = [
JSON.stringify({ t: '2026-01-01T00:00:00Z', event: 'run_started' }),
'{ not valid json',
JSON.stringify({ t: '2026-01-01T00:01:00Z', event: 'phase_started', phase: 'architecture' }),
JSON.stringify({
t: '2026-01-01T00:02:00Z',
event: 'phase_completed',
phase: 'architecture',
status: 'completed',
data: { impactLevel: 'high' },
}),
JSON.stringify({ t: '2026-01-01T00:10:00Z', event: 'run_completed', status: 'completed' }),
].join('\n');

mockFileContents({
'/runs/test-run/run-index.json': JSON.stringify(minimalV3Header()),
'/runs/test-run/run-log.jsonl': lines,
});

const result = await parseRunData('/runs/test-run');

expect(result.status).toBe('completed');
expect(result.completedAt).toBe('2026-01-01T00:10:00Z');
expect(result.phases.architecture).toMatchObject({ status: 'completed', impactLevel: 'high' });
});
});
});
115 changes: 13 additions & 102 deletions packages/factory/src/server/adapters/schemas/run-index-schema.ts
Original file line number Diff line number Diff line change
@@ -1,102 +1,13 @@
import { z } from 'zod';

/** Valid run-level statuses. */
export const runStatusSchema = z.enum(['in_progress', 'completed', 'failed', 'needs_manual_review']);

/** Valid phase-level statuses. */
export const phaseStatusSchema = z.enum(['completed', 'skipped', 'failed', 'in_progress', 'approved']);

/** Valid criticality levels. */
export const criticalitySchema = z.enum(['none', 'low', 'medium', 'high']);

/**
* A single phase entry. Known enum fields are validated when present;
* unknown keys pass through to preserve forward compatibility.
*
* Fields use `.nullish()` because production data contains explicit `null`
* values (meaning "phase not yet reached") as distinct from `undefined`
* (field absent). `.partial()` makes every field optional so keys can be
* omitted entirely.
*/
export const phaseEntrySchema = z
.object({
status: phaseStatusSchema.nullish(),
criticality: criticalitySchema.nullish(),
finalCriticality: criticalitySchema.nullish(),
aggregatedCriticality: criticalitySchema.nullish(),
})
.partial()
.loose();

/**
* Map of phase names to phase entries. Values may be `null` (phase not yet
* reached) or a phase-entry object.
*/
export const phasesSchema = z.record(z.string(), z.union([z.null(), phaseEntrySchema]));

/**
* A single phase-decision entry (run gate). Uses `.loose()` so that
* extra fields (e.g. `disposition`) pass through without rejection.
*/
export const phaseDecisionSchema = z
.object({
run: z.boolean(),
reason: z.string().optional(),
})
.loose();

/** Optional map of phase names to phase decisions. */
export const phaseDecisionMapSchema = z.record(z.string(), phaseDecisionSchema).optional();

/** A single artifact entry with 7 required string fields plus optional metadata. */
export const artifactEntrySchema = z.object({
filename: z.string(),
role: z.string(),
roleType: z.string(),
agent: z.string(),
type: z.string(),
phase: z.string(),
createdAt: z.string(),
iteration: z.number().optional(),
note: z.string().optional(),
});

/** V2 context block: run metadata plus phases and decisions. */
export const v2ContextSchema = z.object({
runId: z.string(),
projectSlug: z.string(),
ticketId: z.string().optional(),
projectRoot: z.string(),
branch: z.string(),
task: z.string(),
startedAt: z.string(),
completedAt: z.string().nullish(),
status: runStatusSchema,
phases: phasesSchema,
phaseDecisions: phaseDecisionMapSchema,
});

/**
* V2 config block: all fields optional. Uses `.loose()` so that extra
* fields (e.g. `pipeline`, `models`) pass through without rejection,
* ensuring forward compatibility as config options evolve.
*/
export const v2ConfigSchema = z
.object({
externalPlan: z.boolean().optional(),
mergeBaseSha: z.string().optional(),
diffBase: z.string().optional(),
maxReviewRounds: z.number().optional(),
fixLowFindings: z.boolean().optional(),
mode: z.string().optional(),
model: z.string().optional(),
})
.loose();

/** Top-level V2 run-index.json schema. */
export const v2RunIndexSchema = z.object({
version: z.literal(2),
context: v2ContextSchema,
config: v2ConfigSchema,
artifacts: z.array(artifactEntrySchema).optional(),
});
export {
artifactEntrySchema,
criticalitySchema,
phaseDecisionMapSchema,
phaseDecisionSchema,
phaseEntrySchema,
phasesSchema,
phaseStatusSchema,
runStatusSchema,
v2ConfigSchema,
v2ContextSchema,
v2RunIndexSchema,
} from '@codeassembly/run-core';
167 changes: 1 addition & 166 deletions packages/factory/src/server/adapters/schemas/run-log-schema.ts
Original file line number Diff line number Diff line change
@@ -1,166 +1 @@
import { z } from 'zod';

import type { RunEvent } from '../../../shared/types/run-log.js';
import { criticalitySchema, runStatusSchema } from './run-index-schema.js';

// -- Individual event schemas --------------------------------------------------
//
// Each schema uses plain `.object()` (not `.loose()`). Zod 4's default behavior
// for `.object()` is to strip unknown keys during parse. This means:
// 1. Lines containing future/unknown fields parse without throwing.
// 2. The inferred output type exactly matches RunEvent (no index signature).
// This eliminates the need for type assertions on the parse result.

const phaseStatusSchema = z.enum(['completed', 'skipped', 'failed', 'in_progress', 'approved']);
const reviewerStatusSchema = z.enum(['completed', 'skipped', 'failed']);

const eventPhaseSchema = z.enum(['architecture', 'planning', 'implementation', 'review', 'simplifier', 'holistic']);

const runStartedSchema = z.object({
t: z.string(),
event: z.literal('run_started'),
});

const runCompletedSchema = z.object({
t: z.string(),
event: z.literal('run_completed'),
status: runStatusSchema,
});

const runFailedSchema = z.object({
t: z.string(),
event: z.literal('run_failed'),
status: runStatusSchema,
reason: z.string().optional(),
});

const phaseDecisionSchema = z.object({
t: z.string(),
event: z.literal('phase_decision'),
phase: z.string(),
run: z.boolean(),
reason: z.string().optional(),
});

const phaseStartedSchema = z.object({
t: z.string(),
event: z.literal('phase_started'),
phase: eventPhaseSchema,
});

const phaseCompletedSchema = z.object({
t: z.string(),
event: z.literal('phase_completed'),
phase: eventPhaseSchema,
status: phaseStatusSchema,
data: z.record(z.string(), z.unknown()).optional(),
});

const reviewerDispatchedSchema = z.object({
t: z.string(),
event: z.literal('reviewer_dispatched'),
reviewer: z.string(),
});

const reviewerCompletedSchema = z.object({
t: z.string(),
event: z.literal('reviewer_completed'),
reviewer: z.string(),
status: reviewerStatusSchema,
criticality: criticalitySchema,
});

const coderFixStartedSchema = z.object({
t: z.string(),
event: z.literal('coder_fix_started'),
iteration: z.number(),
});

const coderFixCompletedSchema = z.object({
t: z.string(),
event: z.literal('coder_fix_completed'),
iteration: z.number(),
});

const reReviewDispatchedSchema = z.object({
t: z.string(),
event: z.literal('re_review_dispatched'),
reviewers: z.array(z.string()),
});

const reReviewCompletedSchema = z.object({
t: z.string(),
event: z.literal('re_review_completed'),
criticalities: z.record(z.string(), criticalitySchema),
});

const artifactWrittenSchema = z.object({
t: z.string(),
event: z.literal('artifact_written'),
filename: z.string(),
role: z.string(),
roleType: z.string(),
agent: z.string(),
type: z.string(),
phase: z.string(),
iteration: z.number().optional(),
note: z.string().optional(),
});

/** Discriminated union over the `event` field covering all 13 event types. */
export const runEventSchema = z.discriminatedUnion('event', [
runStartedSchema,
runCompletedSchema,
runFailedSchema,
phaseDecisionSchema,
phaseStartedSchema,
phaseCompletedSchema,
reviewerDispatchedSchema,
reviewerCompletedSchema,
coderFixStartedSchema,
coderFixCompletedSchema,
reReviewDispatchedSchema,
reReviewCompletedSchema,
artifactWrittenSchema,
]);

/** Parse a single JSONL line into a validated RunEvent. */
export function parseRunLogLine(line: string): RunEvent {
const raw: unknown = JSON.parse(line);
const parsed: z.infer<typeof runEventSchema> = runEventSchema.parse(raw);
// z.infer output is structurally identical to RunEvent when schemas don't use .loose()
return parsed;
}

// -- V3 run-index.json schema -------------------------------------------------

/** V3 context: header-only (no phases, status, completedAt, or phaseDecisions). */
const v3ContextSchema = z.object({
runId: z.string(),
projectSlug: z.string(),
ticketId: z.string().optional(),
projectRoot: z.string(),
branch: z.string(),
task: z.string(),
startedAt: z.string(),
});

/** V3 config: same as v2 — all fields optional, loose for forward compatibility. */
const v3ConfigSchema = z
.object({
externalPlan: z.boolean().optional(),
mergeBaseSha: z.string().optional(),
diffBase: z.string().optional(),
maxReviewRounds: z.number().optional(),
fixLowFindings: z.boolean().optional(),
mode: z.string().optional(),
model: z.string().optional(),
})
.loose();

/** Top-level V3 run-index.json schema (header-only, events live in run-log.jsonl). */
export const v3RunIndexSchema = z.object({
version: z.literal(3),
context: v3ContextSchema,
config: v3ConfigSchema,
});
export { parseRunLogLine, runEventSchema, v3RunIndexSchema } from '@codeassembly/run-core';
Loading