diff --git a/src/cli/commands/execute.helpers.test.ts b/src/cli/commands/execute.helpers.test.ts index f23eb10..b651af7 100644 --- a/src/cli/commands/execute.helpers.test.ts +++ b/src/cli/commands/execute.helpers.test.ts @@ -1,5 +1,6 @@ import { assertValidKataName, + betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, formatDurationMs, @@ -10,6 +11,8 @@ import { parseCompletedRunArtifacts, parseCompletedRunTokenUsage, parseHintFlags, + resolveCompletionStatus, + resolveJsonFlag, } from '@cli/commands/execute.helpers.js'; describe('execute helpers', () => { @@ -421,4 +424,57 @@ describe('execute helpers', () => { ]); }); }); + + describe('resolveJsonFlag', () => { + it('returns true when local json is set', () => { + expect(resolveJsonFlag(true, undefined)).toBe(true); + }); + + it('returns true when global json is set', () => { + expect(resolveJsonFlag(undefined, true)).toBe(true); + expect(resolveJsonFlag(false, true)).toBe(true); + }); + + it('returns true when both are set', () => { + expect(resolveJsonFlag(true, true)).toBe(true); + }); + + it('returns false when neither is set', () => { + expect(resolveJsonFlag(undefined, undefined)).toBe(false); + expect(resolveJsonFlag(false, false)).toBe(false); + }); + }); + + describe('betStatusSymbol', () => { + it('returns looping arrow for in-progress', () => { + expect(betStatusSymbol('in-progress')).toBe('\u27F3'); + }); + + it('returns check for complete', () => { + expect(betStatusSymbol('complete')).toBe('\u2713'); + }); + + it('returns cross for failed', () => { + expect(betStatusSymbol('failed')).toBe('\u2717'); + }); + + it('returns dot for unknown status', () => { + expect(betStatusSymbol('pending')).toBe('\u00B7'); + expect(betStatusSymbol('')).toBe('\u00B7'); + }); + }); + + describe('resolveCompletionStatus', () => { + it('returns failed when flag is true', () => { + expect(resolveCompletionStatus(true)).toBe('failed'); + }); + + it('returns complete when flag is false', () => { + expect(resolveCompletionStatus(false)).toBe('complete'); + }); + + it('returns complete when flag is undefined', () => { + expect(resolveCompletionStatus(undefined)).toBe('complete'); + }); + }); }); diff --git a/src/cli/commands/execute.helpers.ts b/src/cli/commands/execute.helpers.ts index 10986ac..47304a6 100644 --- a/src/cli/commands/execute.helpers.ts +++ b/src/cli/commands/execute.helpers.ts @@ -310,6 +310,21 @@ export function buildPreparedRunOutputLines(result: PreparedRunOutput, agentCont ]; } +export function resolveJsonFlag(localJson: boolean | undefined, globalJson: boolean | undefined): boolean { + return !!(localJson || globalJson); +} + +export function betStatusSymbol(status: string): string { + if (status === 'in-progress') return '\u27F3'; + if (status === 'complete') return '\u2713'; + if (status === 'failed') return '\u2717'; + return '\u00B7'; +} + +export function resolveCompletionStatus(failed: boolean | undefined): 'failed' | 'complete' { + return failed ? 'failed' : 'complete'; +} + export function assertValidKataName(name: string): void { if (!/^[a-zA-Z0-9_-]+$/.test(name)) { throw new Error( diff --git a/src/cli/commands/execute.ts b/src/cli/commands/execute.ts index 7e6e71e..4f6a3ea 100644 --- a/src/cli/commands/execute.ts +++ b/src/cli/commands/execute.ts @@ -23,6 +23,7 @@ import { CycleManager } from '@domain/services/cycle-manager.js'; import { SessionExecutionBridge } from '@infra/execution/session-bridge.js'; import { assertValidKataName, + betStatusSymbol, buildPreparedCycleOutputLines, buildPreparedRunOutputLines, formatDurationMs, @@ -33,6 +34,8 @@ import { parseCompletedRunArtifacts, parseCompletedRunTokenUsage, parseHintFlags, + resolveCompletionStatus, + resolveJsonFlag, } from '@cli/commands/execute.helpers.js'; import { resolveRef } from '@cli/resolve-ref.js'; import { handleStatus, handleStats, parseCategoryFilter } from './status.js'; @@ -102,7 +105,7 @@ export function registerExecuteCommands(program: Command): void { kataka?: string; json?: boolean; }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentId = localOpts.agent ?? localOpts.kataka; @@ -143,7 +146,7 @@ export function registerExecuteCommands(program: Command): void { } console.log(''); for (const bet of result.bets) { - const status = bet.status === 'in-progress' ? '⟳' : bet.status === 'complete' ? '✓' : bet.status === 'failed' ? '✗' : '·'; + const status = betStatusSymbol(bet.status); console.log(` ${status} ${bet.betName} [${bet.status}]`); if (bet.runId) { console.log(` kansatsu: ${bet.kansatsuCount}, maki: ${bet.artifactCount}, kime: ${bet.decisionCount}`); @@ -189,7 +192,7 @@ export function registerExecuteCommands(program: Command): void { outputTokens?: number; json?: boolean; }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const parsedArtifacts = parseCompletedRunArtifacts(localOpts.artifacts); @@ -226,14 +229,14 @@ export function registerExecuteCommands(program: Command): void { if (isJson) { console.log(JSON.stringify({ runId, - status: localOpts.failed ? 'failed' : 'complete', + status: resolveCompletionStatus(localOpts.failed), ...(tokenUsage ? { tokenUsage } : {}), })); } else { const tokenLine = hasTokens ? ` (tokens: ${totalTokens ?? 0} total, ${tokenUsage?.inputTokens ?? 0} in, ${tokenUsage?.outputTokens ?? 0} out)` : ''; - console.log(`Run ${runId} marked as ${localOpts.failed ? 'failed' : 'complete'}.${tokenLine}`); + console.log(`Run ${runId} marked as ${resolveCompletionStatus(localOpts.failed)}.${tokenLine}`); } })); @@ -245,7 +248,7 @@ export function registerExecuteCommands(program: Command): void { .option('--json', 'Wrap output in a JSON object with a "agentContext" key') .action(withCommandContext(async (ctx, runId: string) => { const localOpts = ctx.cmd.opts() as { json?: boolean }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentContext = bridge.getAgentContext(runId); @@ -267,7 +270,7 @@ export function registerExecuteCommands(program: Command): void { .option('--json', 'Output as JSON') .action(withCommandContext(async (ctx) => { const localOpts = ctx.cmd.opts() as { bet: string; agent?: string; kataka?: string; json?: boolean }; - const isJson = !!(localOpts.json || ctx.globalOpts.json); + const isJson = resolveJsonFlag(localOpts.json, ctx.globalOpts.json); const bridge = new SessionExecutionBridge(ctx.kataDir); const agentId = localOpts.agent ?? localOpts.kataka; diff --git a/src/features/cycle-management/cooldown-session.helpers.test.ts b/src/features/cycle-management/cooldown-session.helpers.test.ts index 4442725..085fda2 100644 --- a/src/features/cycle-management/cooldown-session.helpers.test.ts +++ b/src/features/cycle-management/cooldown-session.helpers.test.ts @@ -10,6 +10,9 @@ import { buildSynthesisInputRecord, clampConfidenceWithDelta, filterExecutionHistoryForCycle, + hasFailedCaptures, + isJsonFile, + isSynthesisPendingFile, listCompletedBetDescriptions, mapBridgeRunStatusToIncompleteStatus, mapBridgeRunStatusToSyncedOutcome, @@ -373,4 +376,42 @@ describe('cooldown-session helpers', () => { ])).toEqual(['Complete bet', 'Partial bet']); }); }); + + describe('isJsonFile', () => { + it('returns true for .json files', () => { + expect(isJsonFile('run.json')).toBe(true); + expect(isJsonFile('pending-abc.json')).toBe(true); + }); + + it('returns false for non-.json files', () => { + expect(isJsonFile('readme.md')).toBe(false); + expect(isJsonFile('json')).toBe(false); + expect(isJsonFile('')).toBe(false); + }); + }); + + describe('isSynthesisPendingFile', () => { + it('returns true for pending-*.json files', () => { + expect(isSynthesisPendingFile('pending-abc.json')).toBe(true); + expect(isSynthesisPendingFile('pending-123-456.json')).toBe(true); + }); + + it('returns false when prefix or suffix is wrong', () => { + expect(isSynthesisPendingFile('result-abc.json')).toBe(false); + expect(isSynthesisPendingFile('pending-abc.txt')).toBe(false); + expect(isSynthesisPendingFile('pending-.md')).toBe(false); + expect(isSynthesisPendingFile('')).toBe(false); + }); + }); + + describe('hasFailedCaptures', () => { + it('returns true when failed count is positive', () => { + expect(hasFailedCaptures(1)).toBe(true); + expect(hasFailedCaptures(5)).toBe(true); + }); + + it('returns false when failed count is zero', () => { + expect(hasFailedCaptures(0)).toBe(false); + }); + }); }); diff --git a/src/features/cycle-management/cooldown-session.helpers.ts b/src/features/cycle-management/cooldown-session.helpers.ts index 4ce0201..3a8ae9e 100644 --- a/src/features/cycle-management/cooldown-session.helpers.ts +++ b/src/features/cycle-management/cooldown-session.helpers.ts @@ -292,3 +292,13 @@ export function listCompletedBetDescriptions( .filter((bet) => bet.outcome === 'complete' || bet.outcome === 'partial') .map((bet) => bet.description); } + +export { isJsonFile } from '@shared/lib/file-filters.js'; + +export function isSynthesisPendingFile(filename: string): boolean { + return filename.startsWith('pending-') && filename.endsWith('.json'); +} + +export function hasFailedCaptures(failed: number): boolean { + return failed > 0; +} diff --git a/src/features/cycle-management/cooldown-session.ts b/src/features/cycle-management/cooldown-session.ts index c983bd2..fa923a3 100644 --- a/src/features/cycle-management/cooldown-session.ts +++ b/src/features/cycle-management/cooldown-session.ts @@ -53,6 +53,9 @@ import { shouldWarnOnIncompleteRuns, shouldWriteDojoDiary, shouldWriteDojoSession, + isJsonFile, + isSynthesisPendingFile, + hasFailedCaptures, } from './cooldown-session.helpers.js'; /** @@ -839,7 +842,7 @@ export class CooldownSession { private cleanupStaleSynthesisInputs(synthesisDir: string, cycleId: string): void { try { - const existing = readdirSync(synthesisDir).filter((file) => file.startsWith('pending-') && file.endsWith('.json')); + const existing = readdirSync(synthesisDir).filter(isSynthesisPendingFile); for (const file of existing) { this.removeStaleSynthesisInputFile(synthesisDir, file, cycleId); } @@ -871,7 +874,6 @@ export class CooldownSession { */ private loadBridgeRunIdsByBetId(cycleId: string, bridgeRunsDir: string): Map { const result = new Map(); - if (!existsSync(bridgeRunsDir)) return result; for (const file of this.listJsonFiles(bridgeRunsDir)) { const meta = this.readBridgeRunMeta(join(bridgeRunsDir, file)); @@ -885,7 +887,7 @@ export class CooldownSession { private listJsonFiles(dir: string): string[] { try { - return readdirSync(dir).filter((file) => file.endsWith('.json')); + return readdirSync(dir).filter(isJsonFile); } catch { return []; } @@ -937,7 +939,6 @@ export class CooldownSession { runId: string, ): BetOutcomeRecord['outcome'] | undefined { const bridgeRunPath = join(bridgeRunsDir, `${runId}.json`); - if (!existsSync(bridgeRunPath)) return undefined; const status = this.readBridgeRunMeta(bridgeRunPath)?.status; return mapBridgeRunStatusToSyncedOutcome(status); } @@ -988,7 +989,7 @@ export class CooldownSession { */ private captureCooldownLearnings(report: CooldownReport): number { const attempts = this.captureCooldownLearningDrafts(report); - if (attempts.failed > 0) { + if (hasFailedCaptures(attempts.failed)) { logger.warn(`${attempts.failed} of ${attempts.captured + attempts.failed} cooldown learnings failed to capture. Check previous warnings for details.`); } diff --git a/src/features/kata-agent/kata-agent-confidence-calculator.test.ts b/src/features/kata-agent/kata-agent-confidence-calculator.test.ts index 36beac4..7f717df 100644 --- a/src/features/kata-agent/kata-agent-confidence-calculator.test.ts +++ b/src/features/kata-agent/kata-agent-confidence-calculator.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { KataAgentConfidenceCalculator } from './kata-agent-confidence-calculator.js'; +import { + KataAgentConfidenceCalculator, + resolveRegistryDir, + computeAverageConfidence, +} from './kata-agent-confidence-calculator.js'; import { KataAgentConfidenceProfileSchema } from '@domain/types/kata-agent-confidence.js'; import { createRunTree, appendObservation } from '@infra/persistence/run-store.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; @@ -65,6 +69,55 @@ function seedLearning(knowledgeDir: string, overrides: Partial = // Tests // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Pure helper tests — direct mutation coverage +// --------------------------------------------------------------------------- + +describe('resolveRegistryDir', () => { + it('returns agentDir when both agentDir and katakaDir are provided', () => { + expect(resolveRegistryDir('/agent', '/kataka')).toBe('/agent'); + }); + + it('falls back to katakaDir when agentDir is undefined', () => { + expect(resolveRegistryDir(undefined, '/kataka')).toBe('/kataka'); + }); + + it('returns agentDir when katakaDir is undefined', () => { + expect(resolveRegistryDir('/agent', undefined)).toBe('/agent'); + }); + + it('throws when neither agentDir nor katakaDir is provided', () => { + expect(() => resolveRegistryDir(undefined, undefined)).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); +}); + +describe('computeAverageConfidence', () => { + it('returns 0 for an empty array', () => { + expect(computeAverageConfidence([])).toBe(0); + }); + + it('returns the single value for a one-element array', () => { + expect(computeAverageConfidence([{ confidence: 0.7 }])).toBe(0.7); + }); + + it('returns the arithmetic mean of multiple values', () => { + const learnings = [{ confidence: 0.6 }, { confidence: 0.8 }, { confidence: 1.0 }]; + expect(computeAverageConfidence(learnings)).toBeCloseTo(0.8, 10); + }); + + it('handles all-zero confidence values', () => { + const learnings = [{ confidence: 0 }, { confidence: 0 }]; + expect(computeAverageConfidence(learnings)).toBe(0); + }); + + it('handles all-one confidence values', () => { + const learnings = [{ confidence: 1 }, { confidence: 1 }, { confidence: 1 }]; + expect(computeAverageConfidence(learnings)).toBe(1); + }); +}); + describe('KataAgentConfidenceCalculator', () => { describe('compute()', () => { it('returns a KataAgentConfidenceProfile with correct structure', () => { @@ -178,6 +231,50 @@ describe('KataAgentConfidenceCalculator', () => { expect(profile.observationCount).toBe(0); }); + it('uses agentDir when provided (ignores katakaDir)', () => { + const { runsDir, knowledgeDir, katakaDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + mkdirSync(agentDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ + runsDir, + knowledgeDir, + agentDir, + katakaDir, + }); + const id = randomUUID(); + + calc.compute(id, 'test-agent'); + + // Written to agentDir, not katakaDir + expect(existsSync(join(agentDir, id, 'confidence.json'))).toBe(true); + expect(existsSync(join(katakaDir, id, 'confidence.json'))).toBe(false); + }); + + it('uses agentDir without katakaDir', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + mkdirSync(agentDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir, agentDir }); + const id = randomUUID(); + + const profile = calc.compute(id, 'test-agent'); + expect(profile.katakaId).toBe(id); + expect(existsSync(join(agentDir, id, 'confidence.json'))).toBe(true); + }); + + it('throws when neither agentDir nor katakaDir is provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir }); + + expect(() => calc.compute(randomUUID(), 'test-agent')).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); + it('handles missing knowledgeDir gracefully (learningCount = 0, overallConfidence = 0)', () => { const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); const runsDir = join(base, '.kata', 'runs'); @@ -216,6 +313,40 @@ describe('KataAgentConfidenceCalculator', () => { }); describe('load()', () => { + it('throws when neither agentDir nor katakaDir is provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir }); + + expect(() => calc.load(randomUUID())).toThrow( + 'KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).', + ); + }); + + it('loads from agentDir when both agentDir and katakaDir are provided', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const base = join(tmpdir(), `kata-conf-calc-${randomUUID()}`); + const agentDir = join(base, '.kata', 'agents'); + const katakaDir = join(base, '.kata', 'kataka'); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(katakaDir, { recursive: true }); + + const calc = new KataAgentConfidenceCalculator({ + runsDir, + knowledgeDir, + agentDir, + katakaDir, + }); + const id = randomUUID(); + + // Write profile via compute (goes to agentDir) + calc.compute(id, 'test-agent'); + + // Load should find it from agentDir + const loaded = calc.load(id); + expect(loaded).not.toBeNull(); + expect(loaded!.katakaId).toBe(id); + }); + it('returns null when file does not exist', () => { const { runsDir, knowledgeDir, katakaDir } = makeDirs(); const calc = new KataAgentConfidenceCalculator({ runsDir, knowledgeDir, katakaDir }); diff --git a/src/features/kata-agent/kata-agent-confidence-calculator.ts b/src/features/kata-agent/kata-agent-confidence-calculator.ts index 8215337..0a16948 100644 --- a/src/features/kata-agent/kata-agent-confidence-calculator.ts +++ b/src/features/kata-agent/kata-agent-confidence-calculator.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { JsonStore } from '@infra/persistence/json-store.js'; import { KnowledgeStore } from '@infra/knowledge/knowledge-store.js'; @@ -8,6 +8,32 @@ import { } from '@domain/types/kata-agent-confidence.js'; import { KataAgentObservabilityAggregator } from './kata-agent-observability-aggregator.js'; +// --------------------------------------------------------------------------- +// Pure helpers — extracted for direct unit testing and mutation coverage +// --------------------------------------------------------------------------- + +/** + * Resolve which directory to use for agent registry persistence. + * Prefers `agentDir`; falls back to legacy `katakaDir`. + * Throws if neither is provided. + */ +export function resolveRegistryDir(agentDir?: string, katakaDir?: string): string { + const dir = agentDir ?? katakaDir; + if (!dir) { + throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); + } + return dir; +} + +/** + * Compute the average confidence from a list of learnings. + * Returns 0 when the list is empty. + */ +export function computeAverageConfidence(learnings: ReadonlyArray<{ confidence: number }>): number { + if (learnings.length === 0) return 0; + return learnings.reduce((sum, l) => sum + l.confidence, 0) / learnings.length; +} + // --------------------------------------------------------------------------- // KataAgentConfidenceCalculator — computes and persists per-agent confidence // --------------------------------------------------------------------------- @@ -26,48 +52,34 @@ export class KataAgentConfidenceCalculator { * Writes the result to the configured agent registry directory. */ compute(agentId: string, agentName: string): KataAgentConfidenceProfile { - // 1. Get observability stats (observation counts, agent learning count) const aggregator = new KataAgentObservabilityAggregator(this.deps.runsDir, this.deps.knowledgeDir); const stats = aggregator.computeStats(agentId, agentName); - // 2. Load agent-tier learnings from KnowledgeStore for this agent let overallConfidence = 0; let learningCount = 0; try { const store = new KnowledgeStore(this.deps.knowledgeDir); const agentLearnings = store.query({ tier: 'agent', agentId: agentName }); learningCount = agentLearnings.length; - if (agentLearnings.length > 0) { - overallConfidence = - agentLearnings.reduce((sum, l) => sum + l.confidence, 0) / agentLearnings.length; - } + overallConfidence = computeAverageConfidence(agentLearnings); } catch { // KnowledgeStore unavailable — leave defaults } - // 3. domainScores: empty for now - // TODO: compute per-domain scores from observation domain tags when available - const domainScores: Record = {}; - const profile: KataAgentConfidenceProfile = { agentId, katakaId: agentId, katakaName: agentName, computedAt: new Date().toISOString(), - domainScores, + domainScores: {}, overallConfidence, observationCount: stats.observationCount, learningCount, }; - // 4. Write to the configured agent directory - const registryDir = this.deps.agentDir ?? this.deps.katakaDir; - if (!registryDir) { - throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); - } + const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); - const dir = dirname(confidencePath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + mkdirSync(dirname(confidencePath), { recursive: true }); JsonStore.write(confidencePath, profile, KataAgentConfidenceProfileSchema); return profile; @@ -77,12 +89,8 @@ export class KataAgentConfidenceCalculator { * Load a previously persisted confidence profile. Returns null if absent or corrupt. */ load(agentId: string): KataAgentConfidenceProfile | null { - const registryDir = this.deps.agentDir ?? this.deps.katakaDir; - if (!registryDir) { - throw new Error('KataAgentConfidenceCalculator requires agentDir (or legacy katakaDir).'); - } + const registryDir = resolveRegistryDir(this.deps.agentDir, this.deps.katakaDir); const confidencePath = join(registryDir, agentId, 'confidence.json'); - if (!existsSync(confidencePath)) return null; try { return JsonStore.read(confidencePath, KataAgentConfidenceProfileSchema); } catch { diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts index 18cf834..f922595 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.test.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.test.ts @@ -2,7 +2,13 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { KataAgentObservabilityAggregator } from './kata-agent-observability-aggregator.js'; +import { + KataAgentObservabilityAggregator, + countObservationsByType, + isAttributedToAgent, + isNewerRun, + listRunDirectoryIds, +} from './kata-agent-observability-aggregator.js'; import { createRunTree } from '@infra/persistence/run-store.js'; import { appendObservation } from '@infra/persistence/run-store.js'; import type { Run } from '@domain/types/run-state.js'; @@ -307,4 +313,108 @@ describe('KataAgentObservabilityAggregator', () => { expect(stats.agentLearningCount).toBe(1); }); }); + + describe('lastRunId / lastActiveAt tracking', () => { + it('selects the run with the latest startedAt across multiple runs', () => { + const { runsDir, knowledgeDir } = makeDirs(); + const agentId = randomUUID(); + + const olderRun = makeRun({ katakaId: agentId, startedAt: '2025-01-01T00:00:00Z' }); + const newerRun = makeRun({ katakaId: agentId, startedAt: '2025-06-15T12:00:00Z' }); + + createRunTree(runsDir, olderRun); + createRunTree(runsDir, newerRun); + + const aggregator = new KataAgentObservabilityAggregator(runsDir, knowledgeDir); + const stats = aggregator.computeStats(agentId, 'test-agent'); + + expect(stats.lastRunId).toBe(newerRun.id); + expect(stats.lastActiveAt).toBe('2025-06-15T12:00:00Z'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Pure helper tests +// --------------------------------------------------------------------------- + +describe('isNewerRun', () => { + it('returns true when latestStartedAt is undefined (first run)', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', undefined)).toBe(true); + }); + + it('returns true when startedAt is strictly later', () => { + expect(isNewerRun('2025-06-15T00:00:00Z', '2025-01-01T00:00:00Z')).toBe(true); + }); + + it('returns false when startedAt is earlier', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', '2025-06-15T00:00:00Z')).toBe(false); + }); + + it('returns false when startedAt equals latestStartedAt', () => { + expect(isNewerRun('2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')).toBe(false); + }); +}); + +describe('isAttributedToAgent', () => { + it('returns true when agentId matches', () => { + expect(isAttributedToAgent({ agentId: 'a1' }, 'a1')).toBe(true); + }); + + it('falls back to katakaId when agentId is undefined', () => { + expect(isAttributedToAgent({ katakaId: 'k1' }, 'k1')).toBe(true); + }); + + it('prefers agentId over katakaId', () => { + expect(isAttributedToAgent({ agentId: 'a1', katakaId: 'k1' }, 'a1')).toBe(true); + expect(isAttributedToAgent({ agentId: 'a1', katakaId: 'k1' }, 'k1')).toBe(false); + }); + + it('returns false when neither matches', () => { + expect(isAttributedToAgent({ agentId: 'other' }, 'a1')).toBe(false); + expect(isAttributedToAgent({}, 'a1')).toBe(false); + }); +}); + +describe('countObservationsByType', () => { + it('returns zero count for empty array', () => { + expect(countObservationsByType([])).toEqual({ count: 0, byType: {} }); + }); + + it('counts observations grouped by type', () => { + const obs = [ + { type: 'insight' }, + { type: 'friction' }, + { type: 'insight' }, + { type: 'prediction' }, + ]; + const result = countObservationsByType(obs); + expect(result.count).toBe(4); + expect(result.byType).toEqual({ insight: 2, friction: 1, prediction: 1 }); + }); + + it('handles single observation', () => { + const result = countObservationsByType([{ type: 'insight' }]); + expect(result.count).toBe(1); + expect(result.byType).toEqual({ insight: 1 }); + }); +}); + +describe('listRunDirectoryIds', () => { + it('returns directory names from runsDir', () => { + const { runsDir } = makeDirs(); + mkdirSync(join(runsDir, 'run-a')); + mkdirSync(join(runsDir, 'run-b')); + // Create a non-directory file to verify filtering + writeFileSync(join(runsDir, 'not-a-dir.json'), '{}'); + + const ids = listRunDirectoryIds(runsDir); + expect(ids).toContain('run-a'); + expect(ids).toContain('run-b'); + expect(ids).not.toContain('not-a-dir.json'); + }); + + it('returns empty array for nonexistent directory', () => { + expect(listRunDirectoryIds('/nonexistent/path/xyz')).toEqual([]); + }); }); diff --git a/src/features/kata-agent/kata-agent-observability-aggregator.ts b/src/features/kata-agent/kata-agent-observability-aggregator.ts index 4677395..32ffb93 100644 --- a/src/features/kata-agent/kata-agent-observability-aggregator.ts +++ b/src/features/kata-agent/kata-agent-observability-aggregator.ts @@ -1,4 +1,4 @@ -import { readdirSync, existsSync } from 'node:fs'; +import { readdirSync } from 'node:fs'; import { RunSchema } from '@domain/types/run-state.js'; import { JsonStore } from '@infra/persistence/json-store.js'; import { readObservations, runPaths } from '@infra/persistence/run-store.js'; @@ -34,6 +34,43 @@ export interface KataAgentObservabilityStats { lastActiveAt?: string; } +// --------------------------------------------------------------------------- +// Pure helpers — extracted for direct unit testing and mutation coverage +// --------------------------------------------------------------------------- + +export function isNewerRun(startedAt: string, latestStartedAt: string | undefined): boolean { + return latestStartedAt === undefined || startedAt > latestStartedAt; +} + +export function isAttributedToAgent( + entity: { agentId?: string; katakaId?: string }, + agentId: string, +): boolean { + return (entity.agentId ?? entity.katakaId) === agentId; +} + +export function countObservationsByType( + observations: ReadonlyArray<{ type: string }>, +): { count: number; byType: Record } { + const byType: Record = {}; + let count = 0; + for (const obs of observations) { + count++; + byType[obs.type] = (byType[obs.type] ?? 0) + 1; + } + return { count, byType }; +} + +export function listRunDirectoryIds(runsDir: string): string[] { + try { + return readdirSync(runsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return []; + } +} + // --------------------------------------------------------------------------- // KataAgentObservabilityAggregator // --------------------------------------------------------------------------- @@ -69,17 +106,7 @@ export class KataAgentObservabilityAggregator { }; // --- Step 1: list run directories --- - let runIds: string[] = []; - if (existsSync(this.runsDir)) { - try { - runIds = readdirSync(this.runsDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - } catch { - // runsDir unreadable — return empty stats - return stats; - } - } + const runIds = listRunDirectoryIds(this.runsDir); // Track the most-recent run by startedAt (ISO string — lexicographic comparison is valid) let latestStartedAt: string | undefined; @@ -95,10 +122,10 @@ export class KataAgentObservabilityAggregator { continue; } - if ((run.agentId ?? run.katakaId) !== agentId) continue; + if (!isAttributedToAgent(run, agentId)) continue; // Track the most recent run - if (latestStartedAt === undefined || run.startedAt > latestStartedAt) { + if (isNewerRun(run.startedAt, latestStartedAt)) { latestStartedAt = run.startedAt; stats.lastRunId = run.id; stats.lastRunCycleId = run.cycleId; @@ -121,12 +148,13 @@ export class KataAgentObservabilityAggregator { const allObs = [...runObs, ...stageObs]; // --- Step 2b: filter to observations attributed to this agent --- - const attributed = allObs.filter((o) => (o.agentId ?? o.katakaId) === agentId); + const attributed = allObs.filter((o) => isAttributedToAgent(o, agentId)); // --- Step 2c: count by type --- - for (const obs of attributed) { - stats.observationCount++; - stats.observationsByType[obs.type] = (stats.observationsByType[obs.type] ?? 0) + 1; + const counts = countObservationsByType(attributed); + stats.observationCount += counts.count; + for (const [type, count] of Object.entries(counts.byType)) { + stats.observationsByType[type] = (stats.observationsByType[type] ?? 0) + count; } } diff --git a/src/infrastructure/execution/session-bridge.helpers.test.ts b/src/infrastructure/execution/session-bridge.helpers.test.ts new file mode 100644 index 0000000..20d041e --- /dev/null +++ b/src/infrastructure/execution/session-bridge.helpers.test.ts @@ -0,0 +1,75 @@ +import { + canTransitionCycleState, + hasBridgeRunMetadataChanged, + isJsonFile, +} from './session-bridge.helpers.js'; + +describe('session-bridge helpers', () => { + describe('canTransitionCycleState', () => { + it('allows planning → active', () => { + expect(canTransitionCycleState('planning', 'active')).toBe(true); + }); + + it('allows active → cooldown', () => { + expect(canTransitionCycleState('active', 'cooldown')).toBe(true); + }); + + it('allows cooldown → complete', () => { + expect(canTransitionCycleState('cooldown', 'complete')).toBe(true); + }); + + it('rejects active → complete (skipping cooldown)', () => { + expect(canTransitionCycleState('active', 'complete')).toBe(false); + }); + + it('rejects planning → cooldown (skipping active)', () => { + expect(canTransitionCycleState('planning', 'cooldown')).toBe(false); + }); + + it('rejects backward transitions', () => { + expect(canTransitionCycleState('active', 'planning')).toBe(false); + expect(canTransitionCycleState('complete', 'active')).toBe(false); + }); + + it('rejects same-state transitions', () => { + expect(canTransitionCycleState('active', 'active')).toBe(false); + }); + }); + + describe('hasBridgeRunMetadataChanged', () => { + it('returns false when both fields match', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'A', cycleName: 'C1' }, + )).toBe(false); + }); + + it('returns true when betName differs', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'B', cycleName: 'C1' }, + )).toBe(true); + }); + + it('returns true when cycleName differs', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'A', cycleName: 'C2' }, + )).toBe(true); + }); + + it('returns true when both differ', () => { + expect(hasBridgeRunMetadataChanged( + { betName: 'A', cycleName: 'C1' }, + { betName: 'B', cycleName: 'C2' }, + )).toBe(true); + }); + }); + + describe('isJsonFile (re-exported from shared)', () => { + it('is re-exported and callable', () => { + expect(isJsonFile('data.json')).toBe(true); + expect(isJsonFile('readme.md')).toBe(false); + }); + }); +}); diff --git a/src/infrastructure/execution/session-bridge.helpers.ts b/src/infrastructure/execution/session-bridge.helpers.ts new file mode 100644 index 0000000..659c49d --- /dev/null +++ b/src/infrastructure/execution/session-bridge.helpers.ts @@ -0,0 +1,26 @@ +import type { CycleState } from '@domain/types/cycle.js'; + +export { isJsonFile } from '@shared/lib/file-filters.js'; + +/** + * Check whether a cycle state transition is allowed. + * Valid transitions: planning → active → cooldown → complete. + */ +export function canTransitionCycleState(from: CycleState, to: CycleState): boolean { + const allowedTransitions: Partial> = { + planning: 'active', + active: 'cooldown', + cooldown: 'complete', + }; + return allowedTransitions[from] === to; +} + +/** + * Detect whether bridge-run metadata has changed vs its refreshed values. + */ +export function hasBridgeRunMetadataChanged( + current: { betName?: string; cycleName?: string }, + refreshed: { betName?: string; cycleName?: string }, +): boolean { + return refreshed.betName !== current.betName || refreshed.cycleName !== current.cycleName; +} diff --git a/src/infrastructure/execution/session-bridge.ts b/src/infrastructure/execution/session-bridge.ts index dfa7f7d..cf52fd7 100644 --- a/src/infrastructure/execution/session-bridge.ts +++ b/src/infrastructure/execution/session-bridge.ts @@ -18,6 +18,11 @@ import { type Bet } from '@domain/types/bet.js'; import { StageCategorySchema } from '@domain/types/stage.js'; import { z } from 'zod/v4'; import { JsonStore } from '@infra/persistence/json-store.js'; +import { + canTransitionCycleState, + hasBridgeRunMetadataChanged, + isJsonFile, +} from './session-bridge.helpers.js'; import { createRunTree, readRun, writeRun, runPaths } from '@infra/persistence/run-store.js'; import { KATA_DIRS } from '@shared/constants/paths.js'; import { logger } from '@shared/lib/logger.js'; @@ -420,7 +425,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { throw new Error('No cycles directory found. Run "kata cycle new" first.'); } - const files = readdirSync(cyclesDir).filter((f) => f.endsWith('.json')); + const files = readdirSync(cyclesDir).filter(isJsonFile); for (const file of files) { try { const cycle = JsonStore.read(join(cyclesDir, file), CycleSchema); @@ -437,7 +442,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { private loadCycle(cycleId: string): Cycle { const cyclesDir = join(this.kataDir, KATA_DIRS.cycles); - const files = readdirSync(cyclesDir).filter((f) => f.endsWith('.json')); + const files = readdirSync(cyclesDir).filter(isJsonFile); for (const file of files) { try { @@ -520,7 +525,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { this.writeCycleNameIfChanged(cyclePath, cycle, name); return; } - if (!this.canTransitionCycleState(cycle.state, state)) { + if (!this.canTransition(cycle.state, state)) { logger.warn(`Cannot transition cycle "${cycleId}" from "${cycle.state}" to "${state}".`); return; } @@ -531,14 +536,9 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { } } - private canTransitionCycleState(from: CycleState, to: CycleState): boolean { - const allowedTransitions: Partial> = { - planning: 'active', - active: 'cooldown', - cooldown: 'complete', - }; - - return allowedTransitions[from] === to; + // Delegates to the extracted pure helper for testability. + private canTransition(from: CycleState, to: CycleState): boolean { + return canTransitionCycleState(from, to); } private writeCycleNameIfChanged(cyclePath: string, cycle: Cycle, name?: string): void { @@ -648,7 +648,7 @@ export class SessionExecutionBridge implements ISessionExecutionBridge { cycleName: cycle.name ?? cycle.id, }; - let changed = refreshed.betName !== meta.betName || refreshed.cycleName !== meta.cycleName; + let changed = hasBridgeRunMetadataChanged(meta, refreshed); if (agentId && !meta.agentId) { refreshed.agentId = agentId; diff --git a/src/infrastructure/execution/session-bridge.unit.test.ts b/src/infrastructure/execution/session-bridge.unit.test.ts index 8b8ba26..8e0faad 100644 --- a/src/infrastructure/execution/session-bridge.unit.test.ts +++ b/src/infrastructure/execution/session-bridge.unit.test.ts @@ -8,6 +8,7 @@ import { RunSchema } from '@domain/types/run-state.js'; import type { AgentCompletionResult } from '@domain/ports/session-bridge.js'; import { logger } from '@shared/lib/logger.js'; import { SessionExecutionBridge } from './session-bridge.js'; +import { canTransitionCycleState } from './session-bridge.helpers.js'; function createTestDir(): string { const dir = join(tmpdir(), `kata-bridge-unit-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -188,14 +189,6 @@ describe('SessionExecutionBridge unit coverage', () => { }); it('only allows adjacent forward cycle state transitions', () => { - const bridge = new SessionExecutionBridge(kataDir); - const canTransitionCycleState = (bridge as unknown as { - canTransitionCycleState: ( - from: 'planning' | 'active' | 'cooldown' | 'complete', - to: 'planning' | 'active' | 'cooldown' | 'complete', - ) => boolean; - }).canTransitionCycleState; - expect(canTransitionCycleState('planning', 'active')).toBe(true); expect(canTransitionCycleState('active', 'cooldown')).toBe(true); expect(canTransitionCycleState('cooldown', 'complete')).toBe(true); diff --git a/src/shared/lib/file-filters.test.ts b/src/shared/lib/file-filters.test.ts new file mode 100644 index 0000000..c791d3c --- /dev/null +++ b/src/shared/lib/file-filters.test.ts @@ -0,0 +1,17 @@ +import { isJsonFile } from './file-filters.js'; + +describe('file-filters', () => { + describe('isJsonFile', () => { + it('returns true for .json files', () => { + expect(isJsonFile('data.json')).toBe(true); + expect(isJsonFile('pending-abc.json')).toBe(true); + }); + + it('returns false for non-.json files', () => { + expect(isJsonFile('readme.md')).toBe(false); + expect(isJsonFile('json')).toBe(false); + expect(isJsonFile('')).toBe(false); + expect(isJsonFile('.DS_Store')).toBe(false); + }); + }); +}); diff --git a/src/shared/lib/file-filters.ts b/src/shared/lib/file-filters.ts new file mode 100644 index 0000000..f8ef7df --- /dev/null +++ b/src/shared/lib/file-filters.ts @@ -0,0 +1,7 @@ +/** + * Shared file-filtering predicates used across layers. + */ + +export function isJsonFile(filename: string): boolean { + return filename.endsWith('.json'); +}