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.

This commit is contained in:
Paul Huliganga 2026-03-26 13:44:00 -04:00
parent 87ee00dcb5
commit 012cfb1ddc
6 changed files with 280 additions and 10 deletions

53
docs/morning-report.md Normal file
View File

@ -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 <hours>] [--threshold <minutes>]
```
- `--window <hours>`: How many hours back to look for recent commits (default: 24)
- `--threshold <minutes>`: 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
```

View File

@ -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');
});
});

147
scripts/morning-report.ts Normal file
View File

@ -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;

View File

@ -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"}

View File

@ -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"
]
}

View File

@ -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": [