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:
parent
87ee00dcb5
commit
012cfb1ddc
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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":"77e4cf2f-0c8f-4739-b374-3f39e3d2d9da","eventType":"phase_started","phase":"A","timestamp":"2026-03-26T17:43:12.063Z","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":"0f8dc76c-1f29-4275-87d4-378c55905429","eventType":"phase_started","phase":"B","timestamp":"2026-03-26T17:43:12.064Z","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":"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":"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":"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":"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":"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":"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":"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":"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":"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"}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
{
|
{
|
||||||
"currentPhase": null,
|
"currentPhase": null,
|
||||||
"overallStatus": "completed",
|
"overallStatus": "completed",
|
||||||
"lastUpdated": "2026-03-26T17:42:20.098Z",
|
"lastUpdated": "2026-03-26T17:43:12.075Z",
|
||||||
"lastFailureReason": null,
|
"lastFailureReason": null,
|
||||||
"nextAction": "done",
|
"nextAction": "done",
|
||||||
"completedPhases": [
|
"completedPhases": [
|
||||||
"p1"
|
"phase1",
|
||||||
|
"phase2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"currentPhase": "fail-all",
|
"currentPhase": "fail-all",
|
||||||
"overallStatus": "running",
|
"overallStatus": "running",
|
||||||
"lastUpdated": "2026-03-26T17:41:05.870Z",
|
"lastUpdated": "2026-03-26T17:42:25.509Z",
|
||||||
"lastFailureReason": null,
|
"lastFailureReason": null,
|
||||||
"nextAction": "",
|
"nextAction": "",
|
||||||
"completedPhases": [
|
"completedPhases": [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue