diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index b651af7..69e2c06 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -3,9 +3,14 @@ import { betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, + formatConfidencePercent, formatDurationMs, formatAgentLoadError, formatExplain, + hasBlockedGaps, + hasBridgedGaps, + hasNoGapsToBridge, + hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -477,4 +482,61 @@ describe('execute helpers', () => { expect(resolveCompletionStatus(undefined)).toBe('complete'); }); }); + + describe('hasNoGapsToBridge', () => { + it('returns true when gaps is undefined', () => { + expect(hasNoGapsToBridge(undefined)).toBe(true); + }); + + it('returns true when gaps is empty', () => { + expect(hasNoGapsToBridge([])).toBe(true); + }); + + it('returns false when there are gaps', () => { + expect(hasNoGapsToBridge([{ description: 'gap' }])).toBe(false); + }); + }); + + describe('hasBridgedGaps', () => { + it('returns true for non-empty bridged array', () => { + expect(hasBridgedGaps([{ id: '1' }])).toBe(true); + }); + + it('returns false for empty array', () => { + expect(hasBridgedGaps([])).toBe(false); + }); + }); + + describe('hasBlockedGaps', () => { + it('returns true for non-empty blocked array', () => { + expect(hasBlockedGaps([{ id: '1' }])).toBe(true); + }); + + it('returns false for empty array', () => { + expect(hasBlockedGaps([])).toBe(false); + }); + }); + + describe('formatConfidencePercent', () => { + it('converts decimal confidence to percent string', () => { + expect(formatConfidencePercent(0.75)).toBe('75%'); + expect(formatConfidencePercent(1)).toBe('100%'); + expect(formatConfidencePercent(0)).toBe('0%'); + }); + + it('rounds to nearest integer', () => { + expect(formatConfidencePercent(0.333)).toBe('33%'); + expect(formatConfidencePercent(0.667)).toBe('67%'); + }); + }); + + describe('hasPipelineLearnings', () => { + it('returns true for non-empty learnings', () => { + expect(hasPipelineLearnings(['learning 1'])).toBe(true); + }); + + it('returns false for empty learnings', () => { + expect(hasPipelineLearnings([])).toBe(false); + }); + }); }); diff --git a/src/cli/commands/execute.helpers.ts b/src/cli/commands/execute.helpers.ts index 47304a6..68fadcc 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -342,3 +342,38 @@ export function formatDurationMs(ms: number): string { if (minutes > 0) return `${minutes}m ${seconds % 60}s`; return `${seconds}s`; } + +/** + * Pure predicate: returns true when there are no gaps to bridge. + */ +export function hasNoGapsToBridge(gaps: readonly unknown[] | undefined): boolean { + return !gaps || gaps.length === 0; +} + +/** + * Pure predicate: returns true when bridged gaps should be reported. + */ +export function hasBridgedGaps(bridged: readonly unknown[]): boolean { + return bridged.length > 0; +} + +/** + * Pure predicate: returns true when blocked gaps should halt execution. + */ +export function hasBlockedGaps(blocked: readonly unknown[]): boolean { + return blocked.length > 0; +} + +/** + * Format confidence as a percentage string (0-100, no decimal). + */ +export function formatConfidencePercent(confidence: number): string { + return `${(confidence * 100).toFixed(0)}%`; +} + +/** + * Pure predicate: returns true when pipeline learnings should be printed. + */ +export function hasPipelineLearnings(learnings: readonly string[]): boolean { + return learnings.length > 0; +} diff --git a/src/cli/commands/execute.test.ts b/src/cli/commands/execute.test.ts index 1c1adf0..6ba1361 100644 --- a/src/cli/commands/execute.test.ts +++ b/src/cli/commands/execute.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, readdirSync import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { Command } from 'commander'; -import { registerExecuteCommands } from './execute.js'; +import { registerExecuteCommands, listSavedKatas, loadSavedKata, saveSavedKata, deleteSavedKata } from './execute.js'; import { CycleManager } from '@domain/services/cycle-manager.js'; import { JsonStore } from '@infra/persistence/json-store.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; @@ -1705,3 +1705,115 @@ describe('registerExecuteCommands', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Saved kata CRUD — direct function tests for mutation hardening +// --------------------------------------------------------------------------- + +describe('saved kata CRUD functions', () => { + let tmpBase: string; + + beforeEach(() => { + tmpBase = join(tmpdir(), `kata-crud-${randomUUID()}`); + mkdirSync(tmpBase, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + describe('listSavedKatas', () => { + it('returns empty when katas dir does not exist', () => { + expect(listSavedKatas(tmpBase)).toEqual([]); + }); + + it('returns valid katas and skips non-json files', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'valid.json'), JSON.stringify({ name: 'valid', stages: ['build'] })); + writeFileSync(join(katasDir, 'notes.txt'), 'not json'); + writeFileSync(join(katasDir, 'broken.json'), '{ broken }'); + + const result = listSavedKatas(tmpBase); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('valid'); + expect(result[0]!.stages).toEqual(['build']); + }); + + it('skips files with invalid schema data', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'bad-schema.json'), JSON.stringify({ name: 123 })); + + expect(listSavedKatas(tmpBase)).toEqual([]); + }); + }); + + describe('loadSavedKata', () => { + it('loads a valid saved kata', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'my-kata.json'), JSON.stringify({ + name: 'my-kata', stages: ['research', 'build'], + })); + + const result = loadSavedKata(tmpBase, 'my-kata'); + expect(result.stages).toEqual(['research', 'build']); + }); + + it('throws when kata does not exist', () => { + expect(() => loadSavedKata(tmpBase, 'missing')).toThrow( + 'Kata "missing" not found.', + ); + }); + + it('throws for invalid JSON content', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'broken.json'), '{ broken }'); + + expect(() => loadSavedKata(tmpBase, 'broken')).toThrow( + 'Kata "broken" has invalid JSON:', + ); + }); + + it('throws for valid JSON with invalid schema', () => { + const katasDir = join(tmpBase, 'katas'); + mkdirSync(katasDir, { recursive: true }); + writeFileSync(join(katasDir, 'bad.json'), JSON.stringify({ name: 123 })); + + expect(() => loadSavedKata(tmpBase, 'bad')).toThrow( + 'Kata "bad" has invalid structure.', + ); + }); + }); + + describe('saveSavedKata', () => { + it('creates the katas directory and writes the file', () => { + saveSavedKata(tmpBase, 'new-kata', ['build', 'review']); + + const filePath = join(tmpBase, 'katas', 'new-kata.json'); + expect(existsSync(filePath)).toBe(true); + const data = JSON.parse(readFileSync(filePath, 'utf-8')); + expect(data.name).toBe('new-kata'); + expect(data.stages).toEqual(['build', 'review']); + }); + }); + + describe('deleteSavedKata', () => { + it('deletes an existing saved kata', () => { + saveSavedKata(tmpBase, 'del-kata', ['build']); + const filePath = join(tmpBase, 'katas', 'del-kata.json'); + expect(existsSync(filePath)).toBe(true); + + deleteSavedKata(tmpBase, 'del-kata'); + expect(existsSync(filePath)).toBe(false); + }); + + it('throws when kata does not exist', () => { + expect(() => deleteSavedKata(tmpBase, 'nonexistent')).toThrow( + 'Kata "nonexistent" not found.', + ); + }); + }); +}); diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 4f6a3ea..b2aa18d 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -17,6 +17,7 @@ import { WorkflowRunner } from '@features/execute/workflow-runner.js'; import { GapBridger } from '@features/execute/gap-bridger.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; import { UsageAnalytics } from '@infra/tracking/usage-analytics.js'; +import { isJsonFile } from '@shared/lib/file-filters.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; import { ProjectStateUpdater } from '@features/belt/belt-calculator.js'; import { CycleManager } from '@domain/services/cycle-manager.js'; @@ -26,9 +27,14 @@ import { betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, + formatConfidencePercent, formatDurationMs, formatAgentLoadError, formatExplain, + hasBlockedGaps, + hasBridgedGaps, + hasNoGapsToBridge, + hasPipelineLearnings, mergePinnedFlavors, parseBetOption, parseCompletedRunArtifacts, @@ -657,20 +663,20 @@ function bridgeExecutionGaps(input: { suggestedFlavors: string[]; }>; }): boolean { - if (!input.gaps || input.gaps.length === 0) return true; + if (hasNoGapsToBridge(input.gaps)) return true; const store = new KnowledgeStore(kataDirPath(input.kataDir, 'knowledge')); const bridger = new GapBridger({ knowledgeStore: store }); - const { blocked, bridged } = bridger.bridge(input.gaps); + const { blocked, bridged } = bridger.bridge(input.gaps!); - if (blocked.length > 0) { + if (hasBlockedGaps(blocked)) { console.error(`[kata] Blocked by ${blocked.length} high-severity gap(s):`); for (const gap of blocked) console.error(` • ${gap.description}`); process.exitCode = 1; return false; } - if (bridged.length > 0) { + if (hasBridgedGaps(bridged)) { console.log(`[kata] Captured ${bridged.length} gap(s) as step-tier learnings.`); ProjectStateUpdater.incrementGapsClosed(input.projectStateFile, bridged.length); } @@ -695,7 +701,7 @@ function printSingleCategoryResult(result: StageRunResult, isJson: boolean, opts console.log(''); console.log('Decisions:'); for (const decision of result.decisions) { - console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${(decision.confidence * 100).toFixed(0)}%)`); + console.log(` ${decision.decisionType}: ${decision.selection} (confidence: ${formatConfidencePercent(decision.confidence)})`); } console.log(''); console.log(`Stage artifact: ${result.stageArtifact.name}`); @@ -735,7 +741,7 @@ function printPipelineResult( console.log(` Artifact: ${stageResult.stageArtifact.name}`); } - if (result.pipelineReflection.learnings.length > 0) { + if (hasPipelineLearnings(result.pipelineReflection.learnings)) { console.log(''); console.log('Learnings:'); for (const learning of result.pipelineReflection.learnings) { @@ -801,11 +807,11 @@ function katasDir(kataDir: string): string { return join(kataDir, KATA_DIRS.katas); } -function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { +export function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCategory[]; description?: string }> { const dir = katasDir(kataDir); if (!existsSync(dir)) return []; return readdirSync(dir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => { try { const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8')); @@ -821,7 +827,7 @@ function listSavedKatas(kataDir: string): Array<{ name: string; stages: StageCat .filter((k): k is NonNullable => k !== null); } -function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { +export function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[]; flavorHints?: Record } { assertValidKataName(name); const filePath = join(katasDir(kataDir), `${name}.json`); if (!existsSync(filePath)) { @@ -846,7 +852,7 @@ function loadSavedKata(kataDir: string, name: string): { stages: StageCategory[] } } -function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { +export function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], flavorHints?: Record): void { assertValidKataName(name); const dir = katasDir(kataDir); mkdirSync(dir, { recursive: true }); @@ -854,7 +860,7 @@ function saveSavedKata(kataDir: string, name: string, stages: StageCategory[], f writeFileSync(join(dir, `${name}.json`), JSON.stringify(kata, null, 2), 'utf-8'); } -function deleteSavedKata(kataDir: string, name: string): void { +export function deleteSavedKata(kataDir: string, name: string): void { assertValidKataName(name); const filePath = join(katasDir(kataDir), `${name}.json`); if (!existsSync(filePath)) { diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts index 085fda2..4ca60a9 100644 --- a/src/features/cycle-management/cooldown-session.helpers.test.ts +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -9,9 +9,13 @@ import { buildDojoSessionBuildRequest, buildSynthesisInputRecord, clampConfidenceWithDelta, + collectBridgeRunIds, filterExecutionHistoryForCycle, hasFailedCaptures, + hasMethod, + hasObservations, isJsonFile, + isSyncableBet, isSynthesisPendingFile, listCompletedBetDescriptions, mapBridgeRunStatusToIncompleteStatus, @@ -19,6 +23,7 @@ import { resolveAppliedProposalIds, selectEffectiveBetOutcomes, shouldRecordBetOutcomes, + shouldSyncOutcomes, shouldWarnOnIncompleteRuns, shouldWriteDojoDiary, shouldWriteDojoSession, @@ -414,4 +419,98 @@ describe('cooldown-session helpers', () => { expect(hasFailedCaptures(0)).toBe(false); }); }); + + describe('isSyncableBet', () => { + it('returns true only when outcome is pending AND runId is present', () => { + expect(isSyncableBet({ outcome: 'pending', runId: 'run-1' })).toBe(true); + }); + + it('returns false when outcome is not pending', () => { + expect(isSyncableBet({ outcome: 'complete', runId: 'run-1' })).toBe(false); + expect(isSyncableBet({ outcome: 'partial', runId: 'run-1' })).toBe(false); + expect(isSyncableBet({ outcome: 'abandoned', runId: 'run-1' })).toBe(false); + }); + + it('returns false when runId is missing or undefined', () => { + expect(isSyncableBet({ outcome: 'pending' })).toBe(false); + expect(isSyncableBet({ outcome: 'pending', runId: undefined })).toBe(false); + }); + + it('returns false when runId is empty string', () => { + expect(isSyncableBet({ outcome: 'pending', runId: '' })).toBe(false); + }); + }); + + describe('collectBridgeRunIds', () => { + it('collects betId→runId pairs for matching cycleId', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c1', betId: 'b1', runId: 'r1' }, + { cycleId: 'c2', betId: 'b2', runId: 'r2' }, + { cycleId: 'c1', betId: 'b3', runId: 'r3' }, + ], 'c1'); + + expect(result.size).toBe(2); + expect(result.get('b1')).toBe('r1'); + expect(result.get('b3')).toBe('r3'); + }); + + it('skips records with missing betId or runId', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c1', betId: 'b1' }, + { cycleId: 'c1', runId: 'r2' }, + { cycleId: 'c1' }, + ], 'c1'); + + expect(result.size).toBe(0); + }); + + it('returns empty map when no records match', () => { + const result = collectBridgeRunIds([ + { cycleId: 'c2', betId: 'b1', runId: 'r1' }, + ], 'c1'); + + expect(result.size).toBe(0); + }); + + it('returns empty map for empty input', () => { + expect(collectBridgeRunIds([], 'c1').size).toBe(0); + }); + }); + + describe('hasObservations', () => { + it('returns true for non-empty arrays', () => { + expect(hasObservations([{ id: '1' }])).toBe(true); + expect(hasObservations([1, 2, 3])).toBe(true); + }); + + it('returns false for empty arrays', () => { + expect(hasObservations([])).toBe(false); + }); + }); + + describe('shouldSyncOutcomes', () => { + it('returns true when there are outcomes to sync', () => { + expect(shouldSyncOutcomes([{ betId: 'b1', outcome: 'complete' }])).toBe(true); + }); + + it('returns false when there are no outcomes to sync', () => { + expect(shouldSyncOutcomes([])).toBe(false); + }); + }); + + describe('hasMethod', () => { + it('returns true when the target has the named method', () => { + expect(hasMethod({ checkExpiry: () => {} }, 'checkExpiry')).toBe(true); + }); + + it('returns false when the target does not have the named method', () => { + expect(hasMethod({}, 'checkExpiry')).toBe(false); + expect(hasMethod({ checkExpiry: 42 }, 'checkExpiry')).toBe(false); + }); + + it('returns false for null and undefined targets', () => { + expect(hasMethod(null, 'checkExpiry')).toBe(false); + expect(hasMethod(undefined, 'checkExpiry')).toBe(false); + }); + }); }); diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts index 3a8ae9e..22cd0b0 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -302,3 +302,51 @@ export function isSynthesisPendingFile(filename: string): boolean { export function hasFailedCaptures(failed: number): boolean { return failed > 0; } + +/** + * Pure filter: returns true for bets that are eligible for auto-sync + * (outcome is still 'pending' AND the bet has a runId assigned). + */ +export function isSyncableBet(bet: { outcome: string; runId?: string }): boolean { + return bet.outcome === 'pending' && Boolean(bet.runId); +} + +/** + * Build a betId → runId mapping from an array of bridge-run metadata records. + * Only includes records that match the target cycleId and have both betId and runId. + */ +export function collectBridgeRunIds( + metas: ReadonlyArray<{ cycleId?: string; betId?: string; runId?: string }>, + targetCycleId: string, +): Map { + const result = new Map(); + for (const meta of metas) { + if (meta.cycleId === targetCycleId && meta.betId && meta.runId) { + result.set(meta.betId, meta.runId); + } + } + return result; +} + +/** + * Pure predicate: returns true when the non-empty observations array + * should be appended to the aggregate (length > 0). + */ +export function hasObservations(observations: readonly unknown[]): boolean { + return observations.length > 0; +} + +/** + * Determine whether auto-synced outcomes should be recorded to the cycle. + */ +export function shouldSyncOutcomes(syncedOutcomes: readonly unknown[]): boolean { + return syncedOutcomes.length > 0; +} + +/** + * Pure predicate: returns true when a typeof check confirms the target + * has a given method (used to guard optional checkExpiry in expiry check). + */ +export function hasMethod(target: unknown, methodName: string): boolean { + return typeof (target as Record)?.[methodName] === 'function'; +} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index fa923a3..447b4a4 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -56,6 +56,11 @@ import { isJsonFile, isSynthesisPendingFile, hasFailedCaptures, + isSyncableBet, + collectBridgeRunIds, + hasObservations, + shouldSyncOutcomes, + hasMethod, } from './cooldown-session.helpers.js'; /** @@ -805,7 +810,7 @@ export class CooldownSession { if (!runId) continue; const runObs = this.readObservationsForRun(runId, bet.id); - if (runObs.length > 0) { + if (hasObservations(runObs)) { observations.push(...runObs); } } @@ -873,16 +878,11 @@ export class CooldownSession { * Returns an empty Map when bridgeRunsDir is missing or unreadable. */ private loadBridgeRunIdsByBetId(cycleId: string, bridgeRunsDir: string): Map { - const result = new Map(); - - for (const file of this.listJsonFiles(bridgeRunsDir)) { - const meta = this.readBridgeRunMeta(join(bridgeRunsDir, file)); - if (meta?.cycleId === cycleId && meta.betId && meta.runId) { - result.set(meta.betId, meta.runId); - } - } - - return result; + const files = this.listJsonFiles(bridgeRunsDir); + const metas = files + .map((file) => this.readBridgeRunMeta(join(bridgeRunsDir, file))) + .filter((meta): meta is NonNullable => meta !== undefined); + return collectBridgeRunIds(metas, cycleId); } private listJsonFiles(dir: string): string[] { @@ -919,15 +919,15 @@ export class CooldownSession { const toSync: BetOutcomeRecord[] = []; for (const bet of cycle.bets) { - if (bet.outcome !== 'pending' || !bet.runId) continue; + if (!isSyncableBet(bet)) continue; - const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId); + const outcome = this.readBridgeRunOutcome(bridgeRunsDir, bet.runId!); if (outcome) { toSync.push({ betId: bet.id, outcome }); } } - if (toSync.length > 0) { + if (shouldSyncOutcomes(toSync)) { this.recordBetOutcomes(cycleId, toSync); } @@ -1201,7 +1201,7 @@ export class CooldownSession { */ private runExpiryCheck(): void { try { - if (typeof this.deps.knowledgeStore.checkExpiry !== 'function') return; + if (!hasMethod(this.deps.knowledgeStore, 'checkExpiry')) return; const result = this.deps.knowledgeStore.checkExpiry(); for (const message of buildExpiryCheckMessages(result)) { logger.debug(message); diff --git a/src/features/cycle-management/cooldown-session.unit.test.ts b/src/features/cycle-management/cooldown-session.unit.test.ts index 96b769a..835337e 100644 --- a/src/features/cycle-management/cooldown-session.unit.test.ts +++ b/src/features/cycle-management/cooldown-session.unit.test.ts @@ -494,6 +494,175 @@ describe('CooldownSession unit seams', () => { } }); + it('writeRunDiary skips diary when dojoDir is not configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'No Diary'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Skip diary bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + + const dojoSessionBuilder = { build: vi.fn() }; + const session = new CooldownSession({ + ...fixture.baseDeps, + // dojoDir deliberately NOT set + dojoSessionBuilder, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.run(cycle.id); + + // Diary and session should NOT be written when dojoDir is absent + expect(dojoSessionBuilder.build).not.toHaveBeenCalled(); + expect(result.report).toBeDefined(); + } finally { + fixture.cleanup(); + } + }); + + it('complete writes diary and session when dojoDir and dojoSessionBuilder are configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Complete Diary'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Diary bet', + appetite: 40, + outcome: 'complete', + issueRefs: [], + }); + + const dojoSessionBuilder = { build: vi.fn() }; + const session = new CooldownSession({ + ...fixture.baseDeps, + dojoDir: fixture.dojoDir, + dojoSessionBuilder, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + await session.complete(cycle.id); + + // Both diary and session should be written + expect(dojoSessionBuilder.build).toHaveBeenCalledTimes(1); + } finally { + fixture.cleanup(); + } + }); + + it('run with force=true parameter exercises warning bypass', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 2_000 }, 'Force Bypass'); + const updated = fixture.cycleManager.addBet(cycle.id, { + description: 'Force bet', + appetite: 30, + outcome: 'pending', + issueRefs: [], + }); + const bet = updated.bets[0]!; + const run = makeRun(cycle.id, bet.id, 'running'); + createRunTree(fixture.runsDir, run); + fixture.cycleManager.setRunId(cycle.id, bet.id, run.id); + + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + // Should not warn when force=true + await session.run(cycle.id, [], { force: true }); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('run(s) are still in progress')); + } finally { + vi.restoreAllMocks(); + fixture.cleanup(); + } + }); + + it('prepare with depth parameter overrides default synthesis depth', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 5_000 }, 'Depth Override'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Depth bet', + appetite: 30, + outcome: 'complete', + issueRefs: [], + }); + + const session = new CooldownSession({ + ...fixture.baseDeps, + synthesisDepth: 'minimal', + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.prepare(cycle.id, [], 'thorough'); + const synthesisInput = JSON.parse(readFileSync(result.synthesisInputPath, 'utf-8')); + expect(synthesisInput.depth).toBe('thorough'); + } finally { + fixture.cleanup(); + } + }); + + it('run skips nextKeiko when runsDir is not configured', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 1_000 }, 'No Runs'); + const nextKeikoGen = { generate: vi.fn(() => ({ text: 'test', observationCounts: { friction: 0, gap: 0, insight: 0, total: 0 }, milestoneIssueCount: 0 })) }; + + const session = new CooldownSession({ + cycleManager: fixture.cycleManager, + knowledgeStore: fixture.knowledgeStore, + persistence: JsonStore, + pipelineDir: fixture.pipelineDir, + historyDir: fixture.historyDir, + // runsDir deliberately NOT set + proposalGenerator: { generate: vi.fn(() => []) }, + nextKeikoProposalGenerator: nextKeikoGen, + }); + + const result = await session.run(cycle.id); + + // nextKeiko should NOT be called without runsDir + expect(nextKeikoGen.generate).not.toHaveBeenCalled(); + expect(result.nextKeikoResult).toBeUndefined(); + expect(result.incompleteRuns).toBeUndefined(); + } finally { + fixture.cleanup(); + } + }); + + it('run returns empty incompleteRuns when all bets are complete', async () => { + const fixture = createFixture(); + + try { + const cycle = fixture.cycleManager.create({ tokenBudget: 2_000 }, 'All Complete'); + fixture.cycleManager.addBet(cycle.id, { + description: 'Done bet', + appetite: 30, + outcome: 'complete', + issueRefs: [], + }); + + const session = new CooldownSession({ + ...fixture.baseDeps, + proposalGenerator: { generate: vi.fn(() => []) }, + }); + + const result = await session.run(cycle.id); + expect(result.incompleteRuns).toEqual([]); + } finally { + fixture.cleanup(); + } + }); + it('checkIncompleteRuns prefers bridge metadata and falls back to run.json status', () => { const fixture = createFixture(); diff --git a/src/features/execute/workflow-runner.ts b/src/features/execute/workflow-runner.ts index 9df9e86..42a2bf1 100644 --- a/src/features/execute/workflow-runner.ts +++ b/src/features/execute/workflow-runner.ts @@ -16,6 +16,7 @@ import { ExecutionHistoryEntrySchema } from '@domain/types/history.js'; import { createStageOrchestrator } from '@domain/services/orchestrators/index.js'; import { MetaOrchestrator } from '@domain/services/meta-orchestrator.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; +import { isJsonFile } from '@shared/lib/file-filters.js'; import { logger } from '@shared/lib/logger.js'; import type { UsageAnalytics } from '@infra/tracking/usage-analytics.js'; @@ -307,7 +308,7 @@ export class WorkflowRunner { if (!existsSync(artifactsDir)) return []; return readdirSync(artifactsDir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => f.replace('.json', '')); } @@ -348,7 +349,7 @@ export function listRecentArtifacts(kataDir: string): ArtifactEntry[] { if (!existsSync(artifactsDir)) return []; const files = readdirSync(artifactsDir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .sort() .reverse(); diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts index 20d041e..a6120b2 100644 --- a/src/infrastructure/execution/session-bridge.helpers.test.ts +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -1,7 +1,15 @@ import { canTransitionCycleState, + computeBudgetPercent, + countJsonlContent, + extractHistoryTokenTotal, + findEarliestTimestamp, hasBridgeRunMetadataChanged, isJsonFile, + mapBridgeRunStatus, + matchesCycleRef, + resolveAgentId, + sumTokenTotals, } from './session-bridge.helpers.js'; describe('session-bridge helpers', () => { @@ -72,4 +80,137 @@ describe('session-bridge helpers', () => { expect(isJsonFile('readme.md')).toBe(false); }); }); + + describe('mapBridgeRunStatus', () => { + it('passes through all status values unchanged', () => { + expect(mapBridgeRunStatus('in-progress')).toBe('in-progress'); + expect(mapBridgeRunStatus('complete')).toBe('complete'); + expect(mapBridgeRunStatus('failed')).toBe('failed'); + }); + }); + + describe('findEarliestTimestamp', () => { + it('returns the earliest ISO timestamp from the list', () => { + expect(findEarliestTimestamp([ + '2026-03-16T12:00:00.000Z', + '2026-03-15T08:00:00.000Z', + '2026-03-16T06:00:00.000Z', + ])).toBe('2026-03-15T08:00:00.000Z'); + }); + + it('returns undefined for empty input', () => { + expect(findEarliestTimestamp([])).toBeUndefined(); + }); + + it('returns the only element for single-item arrays', () => { + expect(findEarliestTimestamp(['2026-03-16T12:00:00.000Z'])).toBe('2026-03-16T12:00:00.000Z'); + }); + }); + + describe('matchesCycleRef', () => { + it('matches by id', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'c1')).toBe(true); + }); + + it('matches by name', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'Keiko 1')).toBe(true); + }); + + it('returns false when neither id nor name matches', () => { + expect(matchesCycleRef({ id: 'c1', name: 'Keiko 1' }, 'c2')).toBe(false); + }); + + it('handles undefined name', () => { + expect(matchesCycleRef({ id: 'c1' }, 'c1')).toBe(true); + expect(matchesCycleRef({ id: 'c1' }, 'something')).toBe(false); + }); + }); + + describe('resolveAgentId', () => { + it('returns agentId when present', () => { + expect(resolveAgentId('agent-1', 'kataka-1')).toBe('agent-1'); + }); + + it('falls back to katakaId when agentId is undefined', () => { + expect(resolveAgentId(undefined, 'kataka-1')).toBe('kataka-1'); + }); + + it('returns undefined when both are undefined', () => { + expect(resolveAgentId(undefined, undefined)).toBeUndefined(); + }); + }); + + describe('computeBudgetPercent', () => { + it('returns null when tokenBudget is 0 or undefined', () => { + expect(computeBudgetPercent(500, undefined)).toBeNull(); + expect(computeBudgetPercent(500, 0)).toBeNull(); + }); + + it('computes percent and returns token estimate', () => { + expect(computeBudgetPercent(500, 1000)).toEqual({ percent: 50, tokenEstimate: 500 }); + expect(computeBudgetPercent(1500, 1000)).toEqual({ percent: 150, tokenEstimate: 1500 }); + }); + + it('rounds percent to nearest integer', () => { + expect(computeBudgetPercent(333, 1000)).toEqual({ percent: 33, tokenEstimate: 333 }); + }); + }); + + describe('extractHistoryTokenTotal', () => { + it('returns token total for matching cycle', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c1', tokenUsage: { total: 500 } }, + 'c1', + )).toBe(500); + }); + + it('returns null for non-matching cycle', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c2', tokenUsage: { total: 500 } }, + 'c1', + )).toBeNull(); + }); + + it('returns null when tokenUsage is missing', () => { + expect(extractHistoryTokenTotal({ cycleId: 'c1' }, 'c1')).toBeNull(); + }); + + it('returns null when tokenUsage.total is undefined', () => { + expect(extractHistoryTokenTotal( + { cycleId: 'c1', tokenUsage: {} }, + 'c1', + )).toBeNull(); + }); + }); + + describe('sumTokenTotals', () => { + it('sums numeric values treating null as 0', () => { + expect(sumTokenTotals([100, null, 200, null, 300])).toBe(600); + }); + + it('returns 0 for empty input', () => { + expect(sumTokenTotals([])).toBe(0); + }); + + it('returns 0 for all-null input', () => { + expect(sumTokenTotals([null, null])).toBe(0); + }); + }); + + describe('countJsonlContent', () => { + it('counts lines in non-empty JSONL content', () => { + expect(countJsonlContent('{"a":1}\n{"b":2}\n{"c":3}')).toBe(3); + expect(countJsonlContent('{"a":1}')).toBe(1); + }); + + it('returns 0 for empty or whitespace-only content', () => { + expect(countJsonlContent('')).toBe(0); + expect(countJsonlContent(' ')).toBe(0); + expect(countJsonlContent('\n')).toBe(0); + }); + + it('handles trailing newlines correctly', () => { + expect(countJsonlContent('{"a":1}\n{"b":2}\n')).toBe(2); + }); + }); }); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts index 659c49d..81c8c9a 100644 --- a/src/infrastructure/execution/session-bridge.helpers.ts +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -24,3 +24,83 @@ export function hasBridgeRunMetadataChanged( ): boolean { return refreshed.betName !== current.betName || refreshed.cycleName !== current.cycleName; } + +/** + * Map a bridge-run status to the display status used by getCycleStatus. + * 'in-progress' maps to 'in-progress', everything else passes through. + */ +export function mapBridgeRunStatus(status: T): T { + return status; +} + +/** + * Find the earliest timestamp from a list of ISO strings. + * Returns undefined if the array is empty. + */ +export function findEarliestTimestamp(timestamps: readonly string[]): string | undefined { + if (timestamps.length === 0) return undefined; + return [...timestamps].sort()[0]; +} + +/** + * Match a cycle by ID or name. Used by loadCycle. + */ +export function matchesCycleRef( + cycle: { id: string; name?: string }, + ref: string, +): boolean { + return cycle.id === ref || cycle.name === ref; +} + +/** + * Resolve the agent ID from primary and legacy fields. + */ +export function resolveAgentId( + agentId: string | undefined, + katakaId: string | undefined, +): string | undefined { + return agentId ?? katakaId; +} + +/** + * Compute budget percent from tokens used and budget total. + * Returns null when no budget is configured. + */ +export function computeBudgetPercent( + tokensUsed: number, + tokenBudget: number | undefined, +): { percent: number; tokenEstimate: number } | null { + if (!tokenBudget) return null; + return { + percent: Math.round((tokensUsed / tokenBudget) * 100), + tokenEstimate: tokensUsed, + }; +} + +/** + * Extract token total from a raw history entry for a given cycle. + * Returns null if the entry does not belong to the cycle. + */ +export function extractHistoryTokenTotal( + entry: { cycleId?: string; tokenUsage?: { total?: number } }, + targetCycleId: string, +): number | null { + if (entry.cycleId !== targetCycleId) return null; + return entry.tokenUsage?.total ?? null; +} + +/** + * Sum token totals from multiple entries, treating null as 0. + */ +export function sumTokenTotals(totals: readonly (number | null)[]): number { + return totals.reduce((sum, t) => sum + (t ?? 0), 0); +} + +/** + * Count non-empty lines in a JSONL-format content string. + * Returns 0 for empty or whitespace-only content. + */ +export function countJsonlContent(content: string): number { + const trimmed = content.trim(); + return trimmed ? trimmed.split('\n').length : 0; +} diff --git a/src/infrastructure/execution/session-bridge.ts b/src/infrastructure/execution/session-bridge.ts index cf52fd7..cb987e6 100644 --- a/src/infrastructure/execution/session-bridge.ts +++ b/src/infrastructure/execution/session-bridge.ts @@ -20,8 +20,14 @@ import { z } from 'zod/v4'; import { JsonStore } from '@infra/persistence/json-store.js'; import { canTransitionCycleState, + computeBudgetPercent, + countJsonlContent, + extractHistoryTokenTotal, + findEarliestTimestamp, hasBridgeRunMetadataChanged, isJsonFile, + matchesCycleRef, + resolveAgentId, } from './session-bridge.helpers.js'; import { createRunTree, readRun, writeRun, runPaths } from '@infra/persistence/run-store.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; @@ -357,7 +363,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { betId: meta.betId, betName: meta.betName, runId: meta.runId, - status: meta.status === 'in-progress' ? 'in-progress' : meta.status, + status: meta.status, kansatsuCount: counts.observations, artifactCount: counts.artifacts, decisionCount: counts.decisions, @@ -381,9 +387,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { } private formatElapsedDuration(bridgeRuns: BridgeRunMeta[]): string { - const earliestStart = bridgeRuns - .map((meta) => meta.startedAt) - .sort()[0]; + const earliestStart = findEarliestTimestamp(bridgeRuns.map((meta) => meta.startedAt)); return earliestStart ? this.formatDuration(Date.now() - new Date(earliestStart).getTime()) @@ -447,7 +451,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { for (const file of files) { try { const cycle = JsonStore.read(join(cyclesDir, file), CycleSchema); - if (cycle.id === cycleId || cycle.name === cycleId) { + if (matchesCycleRef(cycle, cycleId)) { return cycle; } } catch { @@ -692,8 +696,8 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { stages: meta.stages, isolation: meta.isolation, startedAt: meta.startedAt, - agentId: meta.agentId ?? meta.katakaId, - katakaId: meta.agentId ?? meta.katakaId, + agentId: resolveAgentId(meta.agentId, meta.katakaId), + katakaId: resolveAgentId(meta.agentId, meta.katakaId), }; } @@ -848,7 +852,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { if (!existsSync(dir)) return []; return readdirSync(dir) - .filter((f) => f.endsWith('.json')) + .filter(isJsonFile) .map((f) => { try { const meta = BridgeRunMetaSchema.parse(JSON.parse(readFileSync(join(dir, f), 'utf-8'))); @@ -897,8 +901,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private countJsonlLines(filePath: string): number { if (!existsSync(filePath)) return 0; try { - const content = readFileSync(filePath, 'utf-8').trim(); - return content ? content.split('\n').length : 0; + return countJsonlContent(readFileSync(filePath, 'utf-8')); } catch { return 0; } @@ -924,17 +927,13 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { if (!existsSync(historyDir)) return { percent: 0, tokenEstimate: 0 }; const totalTokens = this.sumCycleHistoryTokens(historyDir, cycle.id); - - return { - percent: Math.round((totalTokens / cycle.budget.tokenBudget) * 100), - tokenEstimate: totalTokens, - }; + return computeBudgetPercent(totalTokens, cycle.budget.tokenBudget); } private sumCycleHistoryTokens(historyDir: string, cycleId: string): number { let totalTokens = 0; - for (const file of readdirSync(historyDir).filter((entry) => entry.endsWith('.json'))) { + for (const file of readdirSync(historyDir).filter(isJsonFile)) { const entryTotal = this.readHistoryTokenTotal(join(historyDir, file), cycleId); totalTokens += entryTotal ?? 0; } @@ -945,11 +944,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private readHistoryTokenTotal(filePath: string, cycleId: string): number | null { try { const entry = JSON.parse(readFileSync(filePath, 'utf-8')); - if (entry.cycleId !== cycleId) { - return null; - } - - return entry.tokenUsage?.total ?? null; + return extractHistoryTokenTotal(entry, cycleId); } catch { return null; } diff --git a/src/infrastructure/execution/session-bridge.unit.test.ts b/src/infrastructure/execution/session-bridge.unit.test.ts index 8e0faad..bc47a60 100644 --- a/src/infrastructure/execution/session-bridge.unit.test.ts +++ b/src/infrastructure/execution/session-bridge.unit.test.ts @@ -106,6 +106,38 @@ describe('SessionExecutionBridge unit coverage', () => { expect(context).toContain(`- **Run ID**: ${prepared.runId}`); }); + it('getAgentContext rejects a completed run in terminal state', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepare(cycle.bets[0]!.id); + + bridge.complete(prepared.runId, { success: true }); + + expect(() => bridge.getAgentContext(prepared.runId)).toThrow( + `Run "${prepared.runId}" is in terminal state "complete" and cannot be dispatched.`, + ); + }); + + it('getAgentContext rejects a failed run in terminal state', () => { + const cycle = createCycle(kataDir); + const bridge = new SessionExecutionBridge(kataDir); + const prepared = bridge.prepare(cycle.bets[0]!.id); + + bridge.complete(prepared.runId, { success: false }); + + expect(() => bridge.getAgentContext(prepared.runId)).toThrow( + `Run "${prepared.runId}" is in terminal state "failed" and cannot be dispatched.`, + ); + }); + + it('getAgentContext throws for non-existent run', () => { + const bridge = new SessionExecutionBridge(kataDir); + + expect(() => bridge.getAgentContext('nonexistent-run')).toThrow( + 'No bridge run found for run ID "nonexistent-run".', + ); + }); + it('resolves stages for ad-hoc, named, and missing named kata assignments', () => { mkdirSync(join(kataDir, 'katas'), { recursive: true }); writeFileSync(