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
164 changes: 164 additions & 0 deletions packages/client/src/__tests__/terminal-failure-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Schema and registry tests for plan-01-terminal-failure-contract:
* - TerminalFailureScopeSchema accepts all required literals and rejects unknown values
* - safeParseEforgeEvent accepts build:terminal-failure for every required scope literal
* - safeParseEforgeEvent rejects build:terminal-failure with an invalid scope
* - eventRegistry['build:terminal-failure'] exists and summary contains the scope
* - TerminalFailureScopeSchema and TerminalFailureEnvelopeSchema are exported from client
*/

import { describe, it, expect } from 'vitest';
import { safeParseEforgeEvent } from '../events.schemas.js';
import { TerminalFailureScopeSchema, TerminalFailureEnvelopeSchema } from '../events.schemas.js';
import type { TerminalFailureScope, TerminalFailureEnvelope } from '../events.schemas.js';
import { eventRegistry, getEventSummary } from '../event-registry.js';
import { Value } from '@sinclair/typebox/value';

// ---------------------------------------------------------------------------
// TerminalFailureScopeSchema — all required literals
// ---------------------------------------------------------------------------

const REQUIRED_SCOPES: TerminalFailureScope[] = [
'plan', 'post-merge-validation', 'prd-validation', 'acceptance-validation',
'landing', 'artifact-recording', 'daemon', 'compile', 'unknown',
];

describe('TerminalFailureScopeSchema', () => {
it.each(REQUIRED_SCOPES)('accepts required scope literal: %s', (scope) => {
expect(Value.Check(TerminalFailureScopeSchema, scope)).toBe(true);
});

it('rejects a scope outside the required enum', () => {
expect(Value.Check(TerminalFailureScopeSchema, 'invalid-scope')).toBe(false);
expect(Value.Check(TerminalFailureScopeSchema, '')).toBe(false);
expect(Value.Check(TerminalFailureScopeSchema, 'network-error')).toBe(false);
});
});

// ---------------------------------------------------------------------------
// TerminalFailureEnvelopeSchema — required and optional fields
// ---------------------------------------------------------------------------

describe('TerminalFailureEnvelopeSchema', () => {
it('accepts minimal envelope with scope, message, and authoritative', () => {
const envelope: TerminalFailureEnvelope = { scope: 'plan', message: 'Build failed', authoritative: true };
expect(Value.Check(TerminalFailureEnvelopeSchema, envelope)).toBe(true);
});

it('accepts full envelope with all optional fields', () => {
const envelope: TerminalFailureEnvelope = {
scope: 'artifact-recording',
message: 'Artifact recording failed',
authoritative: true,
planId: 'plan-01',
stage: 'artifact-recording',
phaseSummary: 'Phase summary',
phaseStatus: 'failed',
eventType: 'daemon:error',
sourceEventType: 'daemon:error',
sourceEventId: 42,
sourceEventTimestamp: '2026-01-01T00:00:00.000Z',
landing: { status: 'skipped', action: 'pr', reason: 'validation failed' },
validationPassed: true,
prdValidationPassed: true,
acceptanceValidationPassed: false,
};
expect(Value.Check(TerminalFailureEnvelopeSchema, envelope)).toBe(true);
});

it('rejects envelope missing required scope', () => {
const invalid = { message: 'Build failed', authoritative: true };
expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false);
});

it('rejects envelope missing required message', () => {
const invalid = { scope: 'plan', authoritative: true };
expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false);
});

it('rejects envelope missing required authoritative', () => {
const invalid = { scope: 'plan', message: 'Build failed' };
expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false);
});
});

// ---------------------------------------------------------------------------
// safeParseEforgeEvent — build:terminal-failure for all scopes
// ---------------------------------------------------------------------------

describe('safeParseEforgeEvent — build:terminal-failure', () => {
it.each(REQUIRED_SCOPES)('accepts build:terminal-failure with scope: %s', (scope) => {
const event = {
type: 'build:terminal-failure',
runId: 'run-01',
failure: { scope, message: `Terminal failure: ${scope}`, authoritative: true },
timestamp: '2026-01-01T00:00:00.000Z',
};
const result = safeParseEforgeEvent(event);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe('build:terminal-failure');
}
});

it('rejects build:terminal-failure with invalid scope', () => {
const event = {
type: 'build:terminal-failure',
runId: 'run-01',
failure: { scope: 'not-a-valid-scope', message: 'fail', authoritative: true },
timestamp: '2026-01-01T00:00:00.000Z',
};
const result = safeParseEforgeEvent(event);
expect(result.success).toBe(false);
});

it('rejects build:terminal-failure missing failure.scope', () => {
const event = {
type: 'build:terminal-failure',
runId: 'run-01',
failure: { message: 'fail', authoritative: true },
timestamp: '2026-01-01T00:00:00.000Z',
};
const result = safeParseEforgeEvent(event);
expect(result.success).toBe(false);
});

it('rejects build:terminal-failure missing failure.authoritative', () => {
const event = {
type: 'build:terminal-failure',
runId: 'run-01',
failure: { scope: 'plan', message: 'Build failed' },
timestamp: '2026-01-01T00:00:00.000Z',
};
const result = safeParseEforgeEvent(event);
expect(result.success).toBe(false);
});
});

// ---------------------------------------------------------------------------
// eventRegistry — build:terminal-failure entry
// ---------------------------------------------------------------------------

describe('eventRegistry[build:terminal-failure]', () => {
it('exists in the registry', () => {
expect(eventRegistry['build:terminal-failure']).toBeDefined();
});

it('is session-scoped and persisted', () => {
const meta = eventRegistry['build:terminal-failure'];
expect(meta.scope).toBe('session');
expect(meta.persist).toBe(true);
});

it('summary contains the terminal failure scope', () => {
const event = {
type: 'build:terminal-failure' as const,
runId: 'run-01',
failure: { scope: 'artifact-recording' as TerminalFailureScope, message: 'Recording failed', authoritative: true },
timestamp: '2026-01-01T00:00:00.000Z',
};
const summary = getEventSummary(event);
expect(summary).toBeDefined();
expect(summary).toContain('artifact-recording');
});
});
7 changes: 7 additions & 0 deletions packages/client/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ export type {
StackArtifactRef,
StackLayerWire,
// --- eforge:endregion plan-01-stack-contracts-config-state-events ---
// --- eforge:region plan-01-terminal-failure-contract ---
TerminalFailureScope,
TerminalFailureEnvelope,
// --- eforge:endregion plan-01-terminal-failure-contract ---
} from './events.js';

// --- eforge:region system-configuration-view ---
Expand All @@ -254,4 +258,7 @@ export { ORCHESTRATION_MODES, SEVERITY_ORDER, isAlwaysYieldedAgentEvent, REVIEW_
// --- eforge:region plan-01-stack-contracts-config-state-events ---
StackProviderSchema, LandingPublicationActionSchema, StackLayerStatusSchema, StackArtifactRefSchema, StackLayerWireSchema,
// --- eforge:endregion plan-01-stack-contracts-config-state-events ---
// --- eforge:region plan-01-terminal-failure-contract ---
TerminalFailureScopeSchema, TerminalFailureEnvelopeSchema,
// --- eforge:endregion plan-01-terminal-failure-contract ---
} from './events.js';
23 changes: 11 additions & 12 deletions packages/client/src/event-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,7 @@ const eventRegistry = {
project: () => undefined,
},

'session:profile': {
scope: 'session',
persist: false,
},
'session:profile': { scope: 'session', persist: false },

// -------------------------------------------------------------------------
// Phase lifecycle
Expand Down Expand Up @@ -1346,17 +1343,22 @@ const eventRegistry = {
// Recovery analysis
// -------------------------------------------------------------------------

'recovery:start': {
// --- eforge:region plan-01-terminal-failure-contract ---
'build:terminal-failure': {
scope: 'session',
persist: false,
summary: (e) => `Analysing failed build for PRD ${e.prdId}`,
persist: true,
summary: (e) => `Build terminal failure (${e.failure.scope}): ${e.failure.message}`,
},
// --- eforge:endregion plan-01-terminal-failure-contract ---

'recovery:summary': {
'recovery:start': {
scope: 'session',
persist: false,
summary: (e) => `Analysing failed build for PRD ${e.prdId}`,
},

'recovery:summary': { scope: 'session', persist: false },

'recovery:complete': {
scope: 'session',
persist: false,
Expand All @@ -1369,10 +1371,7 @@ const eventRegistry = {
summary: (e) => `Recovery parse failed: ${e.error}`,
},

'recovery:apply:start': {
scope: 'session',
persist: false,
},
'recovery:apply:start': { scope: 'session', persist: false },

'recovery:apply:complete': {
scope: 'session',
Expand Down
80 changes: 39 additions & 41 deletions packages/client/src/events.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,23 @@ const FailingPlanEntrySchema = Type.Object({
// --- eforge:endregion plan-01-recovery-summary-reconstruction ---
});

// --- eforge:region plan-01-terminal-failure-contract ---
export const TerminalFailureScopeSchema = Type.Union([
Type.Literal('plan'), Type.Literal('post-merge-validation'), Type.Literal('prd-validation'),
Type.Literal('acceptance-validation'), Type.Literal('artifact-recording'),
Type.Literal('landing'), Type.Literal('daemon'), Type.Literal('compile'), Type.Literal('unknown'),
]);
const TFLandingSchema = Type.Object({ status: Type.String(), action: Type.Optional(Type.String()), reason: Type.Optional(Type.String()) });
export const TerminalFailureEnvelopeSchema = Type.Object({
scope: TerminalFailureScopeSchema, message: Type.String(),
authoritative: Type.Boolean(), planId: Type.Optional(Type.String()),
stage: Type.Optional(Type.String()), phaseSummary: Type.Optional(Type.String()),
phaseStatus: Type.Optional(Type.String()), eventType: Type.Optional(Type.String()),
sourceEventType: Type.Optional(Type.String()), sourceEventId: Type.Optional(Type.Integer()), sourceEventTimestamp: Type.Optional(Type.String()),
landing: Type.Optional(TFLandingSchema), validationPassed: Type.Optional(Type.Boolean()), prdValidationPassed: Type.Optional(Type.Boolean()), acceptanceValidationPassed: Type.Optional(Type.Boolean()),
});
// --- eforge:endregion plan-01-terminal-failure-contract ---

const BuildFailureSummarySchema = Type.Object({
prdId: Type.String(),
setName: Type.String(),
Expand All @@ -555,12 +572,9 @@ const BuildFailureSummarySchema = Type.Object({
partial: Type.Optional(Type.Boolean()),
prdContent: Type.Optional(Type.String()),
// --- eforge:region plan-01-recovery-and-acceptance-reporting ---
terminalFailure: Type.Optional(Type.Object({
stage: Type.String(),
phaseSummary: Type.Optional(Type.String()),
phaseStatus: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
})),
// --- eforge:region plan-01-terminal-failure-contract ---
terminalFailure: Type.Optional(Type.Partial(TerminalFailureEnvelopeSchema)),
// --- eforge:endregion plan-01-terminal-failure-contract ---
acceptanceValidation: Type.Optional(Type.Object({
passed: Type.Boolean(),
total: Type.Number(),
Expand All @@ -574,11 +588,7 @@ const BuildFailureSummarySchema = Type.Object({
exitCode: Type.Number(),
output: Type.Optional(Type.String()),
}))),
landing: Type.Optional(Type.Object({
status: Type.String(),
action: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
})),
landing: Type.Optional(TFLandingSchema),
// --- eforge:endregion plan-01-recovery-and-acceptance-reporting ---
// --- eforge:region plan-01-recovery-summary-reconstruction ---
failingPlans: Type.Optional(Type.Array(FailingPlanEntrySchema)),
Expand Down Expand Up @@ -881,6 +891,11 @@ export const BuildDecisionSchema = Type.Union([

export type BuildDecision = Static<typeof BuildDecisionSchema>;

const StackSyncTriggerSchema = Type.Optional(Type.Union([
Type.Literal('manual'), Type.Literal('after-build'),
Type.Literal('scheduled'), Type.Literal('retry-deferred'),
]));

const EforgeEventVariantsSchema = Type.Union([
// Session lifecycle
Type.Object({ type: Type.Literal('session:start'), sessionId: Type.String() }),
Expand Down Expand Up @@ -2204,23 +2219,13 @@ const EforgeEventVariantsSchema = Type.Union([
Type.Object({
type: Type.Literal('stack:sync:start'),
syncId: Type.String(),
trigger: Type.Optional(Type.Union([
Type.Literal('manual'),
Type.Literal('after-build'),
Type.Literal('scheduled'),
Type.Literal('retry-deferred'),
])),
trigger: StackSyncTriggerSchema,
dryRun: Type.Boolean(),
}),
Type.Object({
type: Type.Literal('stack:sync:complete'),
syncId: Type.String(),
trigger: Type.Optional(Type.Union([
Type.Literal('manual'),
Type.Literal('after-build'),
Type.Literal('scheduled'),
Type.Literal('retry-deferred'),
])),
trigger: StackSyncTriggerSchema,
dryRun: Type.Boolean(),
restackCandidates: Type.Array(Type.String()),
excludedCandidates: Type.Array(Type.String()),
Expand All @@ -2232,12 +2237,7 @@ const EforgeEventVariantsSchema = Type.Union([
Type.Object({
type: Type.Literal('stack:sync:failed'),
syncId: Type.String(),
trigger: Type.Optional(Type.Union([
Type.Literal('manual'),
Type.Literal('after-build'),
Type.Literal('scheduled'),
Type.Literal('retry-deferred'),
])),
trigger: StackSyncTriggerSchema,
dryRun: Type.Boolean(),
outcome: Type.Union([Type.Literal('failed'), Type.Literal('conflict')]),
reason: Type.String(),
Expand All @@ -2246,30 +2246,24 @@ const EforgeEventVariantsSchema = Type.Union([
Type.Object({
type: Type.Literal('stack:sync:deferred'),
syncId: Type.String(),
trigger: Type.Optional(Type.Union([
Type.Literal('manual'),
Type.Literal('after-build'),
Type.Literal('scheduled'),
Type.Literal('retry-deferred'),
])),
trigger: StackSyncTriggerSchema,
reason: Type.String(),
excludedCandidates: Type.Array(Type.String()),
}),
Type.Object({
type: Type.Literal('stack:sync:skipped'),
syncId: Type.String(),
trigger: Type.Optional(Type.Union([
Type.Literal('manual'),
Type.Literal('after-build'),
Type.Literal('scheduled'),
Type.Literal('retry-deferred'),
])),
trigger: StackSyncTriggerSchema,
dryRun: Type.Boolean(),
reason: Type.String(),
restackCandidates: Type.Array(Type.String()),
excludedCandidates: Type.Array(Type.String()),
}),
// --- eforge:endregion plan-01-core-daemon-stack-sync ---
// --- eforge:region plan-01-terminal-failure-contract ---
Type.Object({ type: Type.Literal('build:terminal-failure'), runId: Type.String(),
failure: TerminalFailureEnvelopeSchema }),
// --- eforge:endregion plan-01-terminal-failure-contract ---
]);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2328,6 +2322,10 @@ export type LandedCommit = Static<typeof LandedCommitSchema>;
export type PlanSummaryEntry = Static<typeof PlanSummaryEntrySchema>;
export type FailingPlanEntry = Static<typeof FailingPlanEntrySchema>;
export type BuildFailureSummary = Static<typeof BuildFailureSummarySchema>;
// --- eforge:region plan-01-terminal-failure-contract ---
export type TerminalFailureScope = Static<typeof TerminalFailureScopeSchema>;
export type TerminalFailureEnvelope = Static<typeof TerminalFailureEnvelopeSchema>;
// --- eforge:endregion plan-01-terminal-failure-contract ---
export type QueueEvent = Static<typeof QueueEventSchema>;
export type PlanningDecisionEvent = Static<typeof PlanningDecisionEventSchema>;
// --- eforge:region plan-01-supervisor-foundation ---
Expand Down
Loading
Loading