From 012cfb1ddcf805601e477e59f3973807570995d4 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Thu, 26 Mar 2026 13:44:00 -0400 Subject: [PATCH] Task 5: Add morning consolidated report script, stalled-state detection, blockers, relays pending detection, tests, and docs. Implements requirements for commit/report inclusion, phase update monitoring, stalled-status detection, and integration tests covering deterministic output. See docs/morning-report.md for usage. --- docs/morning-report.md | 53 ++++++++ scripts/__tests__/morning-report.test.ts | 69 +++++++++++ scripts/morning-report.ts | 147 +++++++++++++++++++++++ status/phase-updates.jsonl | 14 +-- status/test-workflow-status.json | 5 +- status/workflow-status.json | 2 +- 6 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 docs/morning-report.md create mode 100644 scripts/__tests__/morning-report.test.ts create mode 100644 scripts/morning-report.ts diff --git a/docs/morning-report.md b/docs/morning-report.md new file mode 100644 index 0000000..72fb1d8 --- /dev/null +++ b/docs/morning-report.md @@ -0,0 +1,53 @@ +# Morning Report Utility + +Produces a concise consolidated workflow report for relay or status dashboards. + +## Usage + +```sh +npx ts-node scripts/morning-report.ts [--window ] [--threshold ] +``` + +- `--window `: How many hours back to look for recent commits (default: 24) +- `--threshold `: Minutes without progress to consider workflow stalled (default: 60) + +### Output +- Markdown/text summary with: + - Recent commits (hash, message, datetime) + - Current workflow status (phase, overall, last updated) + - Any detected blockers (from status file) + - Pending phase updates not yet relayed + - **Stalled** indicator if workflow progress is stale + recommended next steps + +## Example Output + +``` +# 🌅 Morning Workflow Report + +## Recent Commits (last 24h) +- `abc123` Add orchestrator status tests _(at 2026-03-25T23:05:00.000Z)_ + +## Workflow Status +- Current phase: **parse** +- State: **running** +- Last updated: 2026-03-26T04:12:00.000Z +- Completed phases: fetch, parse + +## ⚠️ Workflow Stalled +- Reason: No progress in 120 minutes (threshold: 60m). Last update: 2026-03-26T04:12:00.000Z +- Recommendation: Restart or debug orchestrator. + +## Blockers +- ❗ Blocked: Recipe site returned 500 error + +## Pending Phase Updates +- [phase_started] Phase parse started (phase: parse) at 2026-03-26T04:12:00.000Z [id: ab1234] +``` + + +## Tests + +Run all related tests: +``` +npx jest scripts/__tests__/morning-report.test.ts +``` diff --git a/scripts/__tests__/morning-report.test.ts b/scripts/__tests__/morning-report.test.ts new file mode 100644 index 0000000..74358fd --- /dev/null +++ b/scripts/__tests__/morning-report.test.ts @@ -0,0 +1,69 @@ +import main from '../morning-report'; + +describe('morning-report: stalled-state and report composition', () => { + const OLD_ENV = process.env; + let logs: string[] = []; + beforeEach(() => { + logs = []; + // @ts-ignore + global.console = { log: (msg: string) => logs.push(msg) }; + jest.resetModules(); + jest.clearAllMocks(); + }); + afterEach(() => { process.env = OLD_ENV }); + + it('detects stalled workflow and formats report', async () => { + const now = new Date('2026-03-26T18:00:00.000Z'); + const fakeStatus = { + currentPhase: 'parse', + overallStatus: 'running', + lastUpdated: '2026-03-26T16:00:00.000Z', + lastFailureReason: null, + nextAction: '', + completedPhases: ['a'] + }; + const fakeCommits = [ + { hash: 'abc123', msg: 'test commit', date: '2026-03-26T10:00:00.000Z' } + ]; + const fakePending = [ + { id: 'foo', eventType: 'phase_started', phase: 'parse', timestamp: '2026-03-26T16:00:00.000Z', summary: 'Phase parse started', relayStatus: 'pending' } + ]; + // Mock deps + jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ + WorkflowStatusManager: jest.fn().mockImplementation(() => ({ + read: async () => fakeStatus + })) + })); + jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ + getPendingPhaseUpdates: async () => fakePending, + getAllPhaseUpdates: async () => fakePending + })); + jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue(fakeCommits); + + // Main + await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); + expect(logs.join('\n')).toContain('⚠️ Workflow Stalled'); + expect(logs.join('\n')).toContain('abc123'); + expect(logs.join('\n')).toContain('phase_started'); + expect(logs.join('\n')).toContain('pending'); + }); + + it('shows no blockers, no stalled, no pending, no commits', async () => { + const now = new Date('2026-03-26T11:00:00.000Z'); + // No status, no events, no commits + jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ + WorkflowStatusManager: jest.fn().mockImplementation(() => ({ + read: async () => null + })) + })); + jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ + getPendingPhaseUpdates: async () => [], + getAllPhaseUpdates: async () => [] + })); + jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue([]); + await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); + expect(logs.join('\n')).toContain('(No commits in window)'); + expect(logs.join('\n')).toContain('No workflow status available'); + expect(logs.join('\n')).toContain('All phase updates relayed'); + }); +}); diff --git a/scripts/morning-report.ts b/scripts/morning-report.ts new file mode 100644 index 0000000..5ca1045 --- /dev/null +++ b/scripts/morning-report.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env ts-node +import { WorkflowStatusManager } from './src/backend/services/WorkflowStatusManager'; +import { getPendingPhaseUpdates, getAllPhaseUpdates } from './src/backend/services/PhaseUpdateQueue'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +// Configurable recency window in hours for recent commits +type ReportConfig = { + commitWindowHours?: number; + statusPath?: string; + phaseUpdatesPath?: string; + stalledThresholdMinutes?: number; + now?: Date; +}; + +const DEFAULT_STATUS_PATH = path.join(process.cwd(), 'status/workflow-status.json'); +const DEFAULT_PHASE_UPDATES_PATH = path.join(process.cwd(), 'status/phase-updates.jsonl'); +const DEFAULT_STALLED_THRESHOLD_MINUTES = 60; + +async function getRecentCommits(windowHours: number): Promise<{hash: string, msg: string, date: string}[]> { + const sinceArg = `--since='${windowHours} hours ago'`; + const cmd = `git log --oneline --date=iso --pretty=format:'%h|%s|%cd' ${sinceArg}`; + const { exec } = require('child_process'); + return new Promise((resolve) => { + exec(cmd, { cwd: process.cwd() }, (err: any, stdout: string) => { + if (err) return resolve([]); + const lines = stdout.trim().split(/\n/); + const result = lines.filter(Boolean).map((line: string) => { + const [hash, msg, date] = line.split('|'); + return {hash, msg, date}; + }); + resolve(result); + }); + }); +} + +function minutesBetween(a: Date, b: Date): number { + return Math.abs((a.getTime() - b.getTime()) / 60000); +} + +function detectStalled(status: any, thresholdMinutes: number, now: Date): {stalled: boolean; reason?: string; recommend?: string} { + if (!status) return {stalled: false}; + if (["completed", "failed", "idle"].includes(status.overallStatus)) return {stalled: false}; + const last = new Date(status.lastUpdated); + const mins = minutesBetween(now, last); + if (mins >= thresholdMinutes) { + return { + stalled: true, + reason: `No progress in ${mins.toFixed(0)} minutes (threshold: ${thresholdMinutes}m). Last update: ${last.toISOString()}`, + recommend: status.overallStatus === "blocked" ? "Manual intervention may be required. See blockers below." : "Restart or debug orchestrator." + }; + } + return {stalled: false}; +} + +function extractBlockers(status: any): string[] { + const out: string[] = []; + if (!status) return out; + if (status.overallStatus === 'blocked') { + if (status.lastFailureReason) out.push(`❗ Blocked: ${status.lastFailureReason}`); + else out.push(`❗ Blocked: Reason not specified.`); + } + if (status.overallStatus === 'failed') { + out.push('❌ Workflow failed.'); + if (status.lastFailureReason) out.push(`Failure: ${status.lastFailureReason}`); + } + if (status.nextAction && status.nextAction.includes('manual')) { + out.push(`⚠️ Manual intervention required: ${status.nextAction}`); + } + return out; +} + +async function main(config: ReportConfig = {}) { + const commitWindow = config.commitWindowHours || 24; + const statusPath = config.statusPath || DEFAULT_STATUS_PATH; + const phaseUpdatesPath = config.phaseUpdatesPath || DEFAULT_PHASE_UPDATES_PATH; + const threshold = config.stalledThresholdMinutes || DEFAULT_STALLED_THRESHOLD_MINUTES; + const now = config.now || new Date(); + + // 1. Recent commits + const commits = await getRecentCommits(commitWindow); + + // 2. Workflow status + const wsm = new WorkflowStatusManager(statusPath); + const status = await wsm.read(); + + // 3. Blockers + const blockers = extractBlockers(status); + + // 4. Pending phase updates + const pendingUpdates = await getPendingPhaseUpdates(); + + // 5. Stalled-state detection + const stalled = detectStalled(status, threshold, now); + + // Render report + let out = `# 🌅 Morning Workflow Report\n`; + + out += `\n## Recent Commits (last ${commitWindow}h)\n`; + if (commits.length) { + commits.forEach(c => { + out += `- \\`${c.hash}\\` ${c.msg} _(at ${c.date})_\n`; + }); + } else { + out += '(No commits in window)\n'; + } + + out += `\n## Workflow Status\n`; + if (status) { + out += `- Current phase: **${status.currentPhase || '—'}**\n`; + out += `- State: **${status.overallStatus}**\n`; + out += `- Last updated: ${status.lastUpdated}\n`; + if (status.completedPhases && status.completedPhases.length) + out += `- Completed phases: ${status.completedPhases.join(', ')}\n`; + } else { + out += '- No workflow status available.\n'; + } + + if (stalled.stalled) { + out += `\n## ⚠️ Workflow Stalled\n- Reason: ${stalled.reason}\n- Recommendation: ${stalled.recommend}\n`; + } + + if (blockers.length) { + out += `\n## Blockers\n`; + blockers.forEach(b => { + out += `- ${b}\n`; + }); + } + + out += `\n## Pending Phase Updates\n`; + if (pendingUpdates.length) { + pendingUpdates.forEach(e => { + out += `- [${e.eventType}] ${e.summary} (phase: ${e.phase}) at ${e.timestamp} [id: ${e.id}]\n`; + }); + } else { + out += '(All phase updates relayed)\n'; + } + + console.log(out); +} + +// CLI wrapper +if (require.main === module) { + main(); +} + +export default main; diff --git a/status/phase-updates.jsonl b/status/phase-updates.jsonl index 0cae43a..c116616 100644 --- a/status/phase-updates.jsonl +++ b/status/phase-updates.jsonl @@ -1,7 +1,7 @@ -{"id":"cbf27f01-9372-49d6-b11a-f1b4d7221ccb","eventType":"phase_started","phase":"A","timestamp":"2026-03-26T17:42:14.281Z","summary":"Phase A started","relayStatus":"pending"} -{"id":"8394da39-3505-4813-8f43-7fe827e48592","eventType":"phase_started","phase":"B","timestamp":"2026-03-26T17:42:14.282Z","summary":"Phase B started","relayStatus":"pending"} -{"id":"bf02c50f-39d0-47ec-ae34-604f81f6cd1b","eventType":"phase_started","phase":"phase1","timestamp":"2026-03-26T17:42:16.167Z","summary":"Phase 'phase1' started","details":{"attempt":1},"relayStatus":"pending"} -{"id":"72e09b8f-f04f-4af2-8198-75ab0f062b7d","eventType":"phase_succeeded","phase":"phase1","timestamp":"2026-03-26T17:42:16.168Z","summary":"Phase 'phase1' succeeded","details":{"attempt":1},"relayStatus":"pending"} -{"id":"c9deaa98-e074-4041-9827-5fded7c997e0","eventType":"phase_started","phase":"phase2","timestamp":"2026-03-26T17:42:16.173Z","summary":"Phase 'phase2' started","details":{"attempt":1},"relayStatus":"pending"} -{"id":"c3c2d170-10f3-4333-a45a-228b0578d100","eventType":"phase_succeeded","phase":"phase2","timestamp":"2026-03-26T17:42:16.174Z","summary":"Phase 'phase2' succeeded","details":{"attempt":1},"relayStatus":"pending"} -{"id":"5f34e79d-bfce-4cb4-a83b-9b74004f12ba","eventType":"workflow_completed","phase":null,"timestamp":"2026-03-26T17:42:16.178Z","summary":"Workflow completed successfully","details":{"totalPhases":2},"relayStatus":"pending"} +{"id":"77e4cf2f-0c8f-4739-b374-3f39e3d2d9da","eventType":"phase_started","phase":"A","timestamp":"2026-03-26T17:43:12.063Z","summary":"Phase A started","relayStatus":"pending"} +{"id":"0f8dc76c-1f29-4275-87d4-378c55905429","eventType":"phase_started","phase":"B","timestamp":"2026-03-26T17:43:12.064Z","summary":"Phase B started","relayStatus":"pending"} +{"id":"d4a04c15-7ec3-4929-9a43-42c6424b4d3a","eventType":"phase_started","phase":"phase1","timestamp":"2026-03-26T17:43:12.065Z","summary":"Phase 'phase1' started","details":{"attempt":1},"relayStatus":"pending"} +{"id":"72476bbd-62e5-4f51-a83d-5052592907ab","eventType":"phase_succeeded","phase":"phase1","timestamp":"2026-03-26T17:43:12.066Z","summary":"Phase 'phase1' succeeded","details":{"attempt":1},"relayStatus":"pending"} +{"id":"55fdc626-770b-4c11-bce3-b525253c148d","eventType":"phase_started","phase":"phase2","timestamp":"2026-03-26T17:43:12.070Z","summary":"Phase 'phase2' started","details":{"attempt":1},"relayStatus":"pending"} +{"id":"c6c2fedb-4891-4d8d-b6a1-30b674495966","eventType":"phase_succeeded","phase":"phase2","timestamp":"2026-03-26T17:43:12.071Z","summary":"Phase 'phase2' succeeded","details":{"attempt":1},"relayStatus":"pending"} +{"id":"b6f54b43-e40c-4f8f-b628-f786cf23ab65","eventType":"workflow_completed","phase":null,"timestamp":"2026-03-26T17:43:12.074Z","summary":"Workflow completed successfully","details":{"totalPhases":2},"relayStatus":"pending"} diff --git a/status/test-workflow-status.json b/status/test-workflow-status.json index 862f3f3..d0c5b3a 100644 --- a/status/test-workflow-status.json +++ b/status/test-workflow-status.json @@ -1,10 +1,11 @@ { "currentPhase": null, "overallStatus": "completed", - "lastUpdated": "2026-03-26T17:42:20.098Z", + "lastUpdated": "2026-03-26T17:43:12.075Z", "lastFailureReason": null, "nextAction": "done", "completedPhases": [ - "p1" + "phase1", + "phase2" ] } \ No newline at end of file diff --git a/status/workflow-status.json b/status/workflow-status.json index ec8dcfc..c578b46 100644 --- a/status/workflow-status.json +++ b/status/workflow-status.json @@ -1,7 +1,7 @@ { "currentPhase": "fail-all", "overallStatus": "running", - "lastUpdated": "2026-03-26T17:41:05.870Z", + "lastUpdated": "2026-03-26T17:42:25.509Z", "lastFailureReason": null, "nextAction": "", "completedPhases": [