@yail259/overnight 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/bun.lock +63 -0
- package/dist/cli.js +26102 -0
- package/package.json +33 -0
- package/src/cli.ts +434 -0
- package/src/notify.ts +50 -0
- package/src/report.ts +115 -0
- package/src/runner.ts +283 -0
- package/src/types.ts +48 -0
- package/tsconfig.json +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yail259/overnight",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Batch job runner for Claude Code - queue tasks, run overnight, wake up to results",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"overnight": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/yail259/overnight.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["claude", "claude-code", "batch", "jobs", "automation", "ai"],
|
|
14
|
+
"author": "yail259",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "bun build src/cli.ts --outdir dist --target node",
|
|
21
|
+
"compile": "bun build src/cli.ts --compile --outfile overnight",
|
|
22
|
+
"dev": "bun run src/cli.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
|
26
|
+
"commander": "^12.0.0",
|
|
27
|
+
"yaml": "^2.3.4"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.11.0",
|
|
31
|
+
"typescript": "^5.3.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import {
|
|
6
|
+
type JobConfig,
|
|
7
|
+
type JobResult,
|
|
8
|
+
type TasksFile,
|
|
9
|
+
DEFAULT_TIMEOUT,
|
|
10
|
+
DEFAULT_STALL_TIMEOUT,
|
|
11
|
+
DEFAULT_VERIFY_PROMPT,
|
|
12
|
+
DEFAULT_STATE_FILE,
|
|
13
|
+
DEFAULT_NTFY_TOPIC,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import {
|
|
16
|
+
runJob,
|
|
17
|
+
runJobsWithState,
|
|
18
|
+
loadState,
|
|
19
|
+
resultsToJson,
|
|
20
|
+
} from "./runner.js";
|
|
21
|
+
import { sendNtfyNotification } from "./notify.js";
|
|
22
|
+
import { generateReport } from "./report.js";
|
|
23
|
+
|
|
24
|
+
const AGENT_HELP = `
|
|
25
|
+
# overnight - Batch Job Runner for Claude Code
|
|
26
|
+
|
|
27
|
+
Queue tasks, run them unattended, get results. Designed for overnight/AFK use.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
\`\`\`bash
|
|
32
|
+
# Create a tasks.yaml file
|
|
33
|
+
overnight init
|
|
34
|
+
|
|
35
|
+
# Run all tasks
|
|
36
|
+
overnight run tasks.yaml
|
|
37
|
+
|
|
38
|
+
# Run with notifications and report
|
|
39
|
+
overnight run tasks.yaml --notify -r report.md
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
| Command | Description |
|
|
45
|
+
|---------|-------------|
|
|
46
|
+
| \`overnight run <file>\` | Run jobs from YAML file |
|
|
47
|
+
| \`overnight resume <file>\` | Resume interrupted run from checkpoint |
|
|
48
|
+
| \`overnight single "<prompt>"\` | Run a single task directly |
|
|
49
|
+
| \`overnight init\` | Create example tasks.yaml |
|
|
50
|
+
|
|
51
|
+
## tasks.yaml Format
|
|
52
|
+
|
|
53
|
+
\`\`\`yaml
|
|
54
|
+
defaults:
|
|
55
|
+
timeout_seconds: 300 # Per-task timeout (default: 300)
|
|
56
|
+
verify: true # Run verification pass (default: true)
|
|
57
|
+
allowed_tools: # Whitelist tools (default: Read,Edit,Write,Glob,Grep)
|
|
58
|
+
- Read
|
|
59
|
+
- Edit
|
|
60
|
+
- Glob
|
|
61
|
+
- Grep
|
|
62
|
+
|
|
63
|
+
tasks:
|
|
64
|
+
# Simple format
|
|
65
|
+
- "Fix the bug in auth.py"
|
|
66
|
+
|
|
67
|
+
# Detailed format
|
|
68
|
+
- prompt: "Add input validation"
|
|
69
|
+
timeout_seconds: 600
|
|
70
|
+
verify: false
|
|
71
|
+
allowed_tools: [Read, Edit, Bash, Glob, Grep]
|
|
72
|
+
\`\`\`
|
|
73
|
+
|
|
74
|
+
## Key Options
|
|
75
|
+
|
|
76
|
+
| Option | Description |
|
|
77
|
+
|--------|-------------|
|
|
78
|
+
| \`-o, --output <file>\` | Save results JSON |
|
|
79
|
+
| \`-r, --report <file>\` | Generate markdown report |
|
|
80
|
+
| \`-s, --state-file <file>\` | Custom checkpoint file |
|
|
81
|
+
| \`--notify\` | Send push notification via ntfy.sh |
|
|
82
|
+
| \`--notify-topic <topic>\` | ntfy.sh topic (default: overnight) |
|
|
83
|
+
| \`-q, --quiet\` | Minimal output |
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
1. **Crash Recovery**: Auto-checkpoints after each job. Use \`overnight resume\` to continue.
|
|
88
|
+
2. **Retry Logic**: Auto-retries 3x on API/network errors with exponential backoff.
|
|
89
|
+
3. **Notifications**: \`--notify\` sends summary to ntfy.sh (free, no signup).
|
|
90
|
+
4. **Reports**: \`-r report.md\` generates markdown summary with next steps.
|
|
91
|
+
5. **Security**: No Bash by default. Whitelist tools per-task.
|
|
92
|
+
|
|
93
|
+
## Example Workflows
|
|
94
|
+
|
|
95
|
+
\`\`\`bash
|
|
96
|
+
# Development: run overnight, check in morning
|
|
97
|
+
nohup overnight run tasks.yaml --notify -r report.md -o results.json > overnight.log 2>&1 &
|
|
98
|
+
|
|
99
|
+
# CI/CD: run and fail if any task fails
|
|
100
|
+
overnight run tasks.yaml -q
|
|
101
|
+
|
|
102
|
+
# Single task with Bash access
|
|
103
|
+
overnight single "Run tests and fix failures" -T Read -T Edit -T Bash -T Glob
|
|
104
|
+
|
|
105
|
+
# Resume after crash/interrupt
|
|
106
|
+
overnight resume tasks.yaml
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
## Exit Codes
|
|
110
|
+
|
|
111
|
+
- 0: All tasks succeeded
|
|
112
|
+
- 1: One or more tasks failed
|
|
113
|
+
|
|
114
|
+
## Files Created
|
|
115
|
+
|
|
116
|
+
- \`.overnight-state.json\` - Checkpoint file (deleted on success)
|
|
117
|
+
- \`report.md\` - Summary report (if -r used)
|
|
118
|
+
- \`results.json\` - Full results (if -o used)
|
|
119
|
+
|
|
120
|
+
Run \`overnight <command> --help\` for command-specific options.
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
function parseTasksFile(path: string): JobConfig[] {
|
|
124
|
+
const content = readFileSync(path, "utf-8");
|
|
125
|
+
const data = parseYaml(content) as TasksFile | (string | JobConfig)[];
|
|
126
|
+
|
|
127
|
+
const tasks = Array.isArray(data) ? data : data.tasks ?? [];
|
|
128
|
+
const defaults = Array.isArray(data) ? {} : data.defaults ?? {};
|
|
129
|
+
|
|
130
|
+
return tasks.map((task) => {
|
|
131
|
+
if (typeof task === "string") {
|
|
132
|
+
return {
|
|
133
|
+
prompt: task,
|
|
134
|
+
timeout_seconds: defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
|
|
135
|
+
stall_timeout_seconds:
|
|
136
|
+
defaults.stall_timeout_seconds ?? DEFAULT_STALL_TIMEOUT,
|
|
137
|
+
verify: defaults.verify ?? true,
|
|
138
|
+
verify_prompt: defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
|
|
139
|
+
allowed_tools: defaults.allowed_tools,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
prompt: task.prompt,
|
|
144
|
+
working_dir: task.working_dir ?? undefined,
|
|
145
|
+
timeout_seconds:
|
|
146
|
+
task.timeout_seconds ?? defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
|
|
147
|
+
stall_timeout_seconds:
|
|
148
|
+
task.stall_timeout_seconds ??
|
|
149
|
+
defaults.stall_timeout_seconds ??
|
|
150
|
+
DEFAULT_STALL_TIMEOUT,
|
|
151
|
+
verify: task.verify ?? defaults.verify ?? true,
|
|
152
|
+
verify_prompt:
|
|
153
|
+
task.verify_prompt ?? defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
|
|
154
|
+
allowed_tools: task.allowed_tools ?? defaults.allowed_tools,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function printSummary(results: JobResult[]): void {
|
|
160
|
+
const statusColors: Record<string, string> = {
|
|
161
|
+
success: "\x1b[32m",
|
|
162
|
+
failed: "\x1b[31m",
|
|
163
|
+
timeout: "\x1b[33m",
|
|
164
|
+
stalled: "\x1b[35m",
|
|
165
|
+
verification_failed: "\x1b[33m",
|
|
166
|
+
};
|
|
167
|
+
const reset = "\x1b[0m";
|
|
168
|
+
const bold = "\x1b[1m";
|
|
169
|
+
|
|
170
|
+
console.log(`\n${bold}Job Results${reset}`);
|
|
171
|
+
console.log("─".repeat(70));
|
|
172
|
+
|
|
173
|
+
results.forEach((r, i) => {
|
|
174
|
+
const color = statusColors[r.status] ?? "";
|
|
175
|
+
const task = r.task.length > 40 ? r.task.slice(0, 40) + "..." : r.task;
|
|
176
|
+
const verified = r.verified ? "✓" : "✗";
|
|
177
|
+
console.log(
|
|
178
|
+
`${i + 1}. ${color}${r.status.padEnd(12)}${reset} ${r.duration_seconds.toFixed(1).padStart(6)}s ${verified} ${task}`
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
183
|
+
console.log(
|
|
184
|
+
`\n${bold}Summary:${reset} ${succeeded}/${results.length} succeeded`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const program = new Command();
|
|
189
|
+
|
|
190
|
+
program
|
|
191
|
+
.name("overnight")
|
|
192
|
+
.description("Batch job runner for Claude Code")
|
|
193
|
+
.version("0.1.0")
|
|
194
|
+
.action(() => {
|
|
195
|
+
console.log(AGENT_HELP);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
program
|
|
199
|
+
.command("run")
|
|
200
|
+
.description("Run jobs from a YAML tasks file")
|
|
201
|
+
.argument("<tasks-file>", "Path to tasks.yaml file")
|
|
202
|
+
.option("-o, --output <file>", "Output file for results JSON")
|
|
203
|
+
.option("-q, --quiet", "Minimal output")
|
|
204
|
+
.option("-s, --state-file <file>", "Custom state file path")
|
|
205
|
+
.option("--notify", "Send push notification via ntfy.sh")
|
|
206
|
+
.option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
|
|
207
|
+
.option("-r, --report <file>", "Generate markdown report")
|
|
208
|
+
.action(async (tasksFile, opts) => {
|
|
209
|
+
if (!existsSync(tasksFile)) {
|
|
210
|
+
console.error(`Error: File not found: ${tasksFile}`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const configs = parseTasksFile(tasksFile);
|
|
215
|
+
if (configs.length === 0) {
|
|
216
|
+
console.error("No tasks found in file");
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m\n`);
|
|
221
|
+
|
|
222
|
+
const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
|
|
223
|
+
const startTime = Date.now();
|
|
224
|
+
|
|
225
|
+
const results = await runJobsWithState(configs, {
|
|
226
|
+
stateFile: opts.stateFile,
|
|
227
|
+
log,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const totalDuration = (Date.now() - startTime) / 1000;
|
|
231
|
+
|
|
232
|
+
if (opts.notify) {
|
|
233
|
+
const success = await sendNtfyNotification(
|
|
234
|
+
results,
|
|
235
|
+
totalDuration,
|
|
236
|
+
opts.notifyTopic
|
|
237
|
+
);
|
|
238
|
+
if (success) {
|
|
239
|
+
console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
|
|
240
|
+
} else {
|
|
241
|
+
console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (opts.report) {
|
|
246
|
+
generateReport(results, totalDuration, opts.report);
|
|
247
|
+
console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!opts.quiet) {
|
|
251
|
+
printSummary(results);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (opts.output) {
|
|
255
|
+
writeFileSync(opts.output, resultsToJson(results));
|
|
256
|
+
console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (results.some((r) => r.status !== "success")) {
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
program
|
|
265
|
+
.command("resume")
|
|
266
|
+
.description("Resume a previous run from saved state")
|
|
267
|
+
.argument("<tasks-file>", "Path to tasks.yaml file")
|
|
268
|
+
.option("-o, --output <file>", "Output file for results JSON")
|
|
269
|
+
.option("-q, --quiet", "Minimal output")
|
|
270
|
+
.option("-s, --state-file <file>", "Custom state file path")
|
|
271
|
+
.option("--notify", "Send push notification via ntfy.sh")
|
|
272
|
+
.option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
|
|
273
|
+
.option("-r, --report <file>", "Generate markdown report")
|
|
274
|
+
.action(async (tasksFile, opts) => {
|
|
275
|
+
const stateFile = opts.stateFile ?? DEFAULT_STATE_FILE;
|
|
276
|
+
const state = loadState(stateFile);
|
|
277
|
+
|
|
278
|
+
if (!state) {
|
|
279
|
+
console.error(`No state file found at ${stateFile}`);
|
|
280
|
+
console.error("Run 'overnight run' first to start jobs.");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!existsSync(tasksFile)) {
|
|
285
|
+
console.error(`Error: File not found: ${tasksFile}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const configs = parseTasksFile(tasksFile);
|
|
290
|
+
if (configs.length === 0) {
|
|
291
|
+
console.error("No tasks found in file");
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (configs.length !== state.total_jobs) {
|
|
296
|
+
console.error(
|
|
297
|
+
`Task file has ${configs.length} jobs but state has ${state.total_jobs}`
|
|
298
|
+
);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const startIndex = state.completed_indices.length;
|
|
303
|
+
console.log(
|
|
304
|
+
`\x1b[1movernight: Resuming from job ${startIndex + 1}/${configs.length}...\x1b[0m`
|
|
305
|
+
);
|
|
306
|
+
console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m\n`);
|
|
307
|
+
|
|
308
|
+
const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
|
|
309
|
+
const startTime = Date.now();
|
|
310
|
+
|
|
311
|
+
const results = await runJobsWithState(configs, {
|
|
312
|
+
stateFile,
|
|
313
|
+
log,
|
|
314
|
+
startIndex,
|
|
315
|
+
priorResults: state.results,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const totalDuration = (Date.now() - startTime) / 1000;
|
|
319
|
+
|
|
320
|
+
if (opts.notify) {
|
|
321
|
+
const success = await sendNtfyNotification(
|
|
322
|
+
results,
|
|
323
|
+
totalDuration,
|
|
324
|
+
opts.notifyTopic
|
|
325
|
+
);
|
|
326
|
+
if (success) {
|
|
327
|
+
console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
|
|
328
|
+
} else {
|
|
329
|
+
console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (opts.report) {
|
|
334
|
+
generateReport(results, totalDuration, opts.report);
|
|
335
|
+
console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!opts.quiet) {
|
|
339
|
+
printSummary(results);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (opts.output) {
|
|
343
|
+
writeFileSync(opts.output, resultsToJson(results));
|
|
344
|
+
console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (results.some((r) => r.status !== "success")) {
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
program
|
|
353
|
+
.command("single")
|
|
354
|
+
.description("Run a single job directly")
|
|
355
|
+
.argument("<prompt>", "The task prompt")
|
|
356
|
+
.option("-t, --timeout <seconds>", "Timeout in seconds", "300")
|
|
357
|
+
.option("--verify", "Run verification pass", true)
|
|
358
|
+
.option("--no-verify", "Skip verification pass")
|
|
359
|
+
.option("-T, --tools <tool...>", "Allowed tools")
|
|
360
|
+
.action(async (prompt, opts) => {
|
|
361
|
+
const config: JobConfig = {
|
|
362
|
+
prompt,
|
|
363
|
+
timeout_seconds: parseInt(opts.timeout, 10),
|
|
364
|
+
verify: opts.verify,
|
|
365
|
+
allowed_tools: opts.tools,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const log = (msg: string) => console.log(msg);
|
|
369
|
+
const result = await runJob(config, log);
|
|
370
|
+
|
|
371
|
+
if (result.status === "success") {
|
|
372
|
+
console.log("\n\x1b[32mSuccess\x1b[0m");
|
|
373
|
+
if (result.result) {
|
|
374
|
+
console.log(result.result);
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
console.log(`\n\x1b[31m${result.status}\x1b[0m`);
|
|
378
|
+
if (result.error) {
|
|
379
|
+
console.log(`\x1b[31m${result.error}\x1b[0m`);
|
|
380
|
+
}
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
program
|
|
386
|
+
.command("init")
|
|
387
|
+
.description("Create an example tasks.yaml file")
|
|
388
|
+
.action(() => {
|
|
389
|
+
const example = `# overnight task file
|
|
390
|
+
# Run with: overnight run tasks.yaml
|
|
391
|
+
|
|
392
|
+
defaults:
|
|
393
|
+
timeout_seconds: 300 # 5 minutes per task
|
|
394
|
+
verify: true # Run verification after each task
|
|
395
|
+
# Secure defaults - no Bash, just file operations
|
|
396
|
+
allowed_tools:
|
|
397
|
+
- Read
|
|
398
|
+
- Edit
|
|
399
|
+
- Write
|
|
400
|
+
- Glob
|
|
401
|
+
- Grep
|
|
402
|
+
|
|
403
|
+
tasks:
|
|
404
|
+
# Simple string format
|
|
405
|
+
- "Find and fix any TODO comments in the codebase"
|
|
406
|
+
|
|
407
|
+
# Dict format with overrides
|
|
408
|
+
- prompt: "Add input validation to all form handlers"
|
|
409
|
+
timeout_seconds: 600 # Allow more time
|
|
410
|
+
|
|
411
|
+
- prompt: "Review code for security issues"
|
|
412
|
+
verify: false # Don't need to verify a review
|
|
413
|
+
|
|
414
|
+
# Can add Bash for specific tasks that need it
|
|
415
|
+
- prompt: "Run the test suite and fix any failures"
|
|
416
|
+
allowed_tools:
|
|
417
|
+
- Read
|
|
418
|
+
- Edit
|
|
419
|
+
- Bash
|
|
420
|
+
- Glob
|
|
421
|
+
- Grep
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
if (existsSync("tasks.yaml")) {
|
|
425
|
+
console.log("\x1b[33mtasks.yaml already exists\x1b[0m");
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
writeFileSync("tasks.yaml", example);
|
|
430
|
+
console.log("\x1b[32mCreated tasks.yaml\x1b[0m");
|
|
431
|
+
console.log("Edit the file, then run: \x1b[1movernight run tasks.yaml\x1b[0m");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
program.parse();
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type JobResult, DEFAULT_NTFY_TOPIC } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export async function sendNtfyNotification(
|
|
4
|
+
results: JobResult[],
|
|
5
|
+
totalDuration: number,
|
|
6
|
+
topic: string = DEFAULT_NTFY_TOPIC
|
|
7
|
+
): Promise<boolean> {
|
|
8
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
9
|
+
const failed = results.length - succeeded;
|
|
10
|
+
|
|
11
|
+
// Format duration
|
|
12
|
+
let durationStr: string;
|
|
13
|
+
if (totalDuration >= 3600) {
|
|
14
|
+
const hours = Math.floor(totalDuration / 3600);
|
|
15
|
+
const mins = Math.floor((totalDuration % 3600) / 60);
|
|
16
|
+
durationStr = `${hours}h ${mins}m`;
|
|
17
|
+
} else if (totalDuration >= 60) {
|
|
18
|
+
const mins = Math.floor(totalDuration / 60);
|
|
19
|
+
const secs = Math.floor(totalDuration % 60);
|
|
20
|
+
durationStr = `${mins}m ${secs}s`;
|
|
21
|
+
} else {
|
|
22
|
+
durationStr = `${totalDuration.toFixed(0)}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const title =
|
|
26
|
+
failed === 0
|
|
27
|
+
? `overnight: ${succeeded}/${results.length} succeeded`
|
|
28
|
+
: `overnight: ${failed} failed`;
|
|
29
|
+
|
|
30
|
+
const message = `Completed in ${durationStr}\n${succeeded} succeeded, ${failed} failed`;
|
|
31
|
+
|
|
32
|
+
const priority = failed === 0 ? "default" : "high";
|
|
33
|
+
const tags = failed === 0 ? "white_check_mark" : "warning";
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(`https://ntfy.sh/${topic}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Title: title,
|
|
40
|
+
Priority: priority,
|
|
41
|
+
Tags: tags,
|
|
42
|
+
},
|
|
43
|
+
body: message,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return response.ok;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
import { type JobResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function generateReport(
|
|
5
|
+
results: JobResult[],
|
|
6
|
+
totalDuration: number,
|
|
7
|
+
outputPath?: string
|
|
8
|
+
): string {
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
|
|
11
|
+
// Header
|
|
12
|
+
lines.push("# Overnight Run Report");
|
|
13
|
+
lines.push("");
|
|
14
|
+
lines.push(`**Generated:** ${new Date().toISOString().replace("T", " ").split(".")[0]}`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
|
|
17
|
+
// Summary
|
|
18
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
19
|
+
const failed = results.length - succeeded;
|
|
20
|
+
|
|
21
|
+
lines.push("## Summary");
|
|
22
|
+
lines.push("");
|
|
23
|
+
lines.push(`- **Jobs:** ${succeeded}/${results.length} succeeded`);
|
|
24
|
+
if (failed > 0) {
|
|
25
|
+
lines.push(`- **Failed:** ${failed}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Duration formatting
|
|
29
|
+
let durationStr: string;
|
|
30
|
+
if (totalDuration >= 3600) {
|
|
31
|
+
const hours = Math.floor(totalDuration / 3600);
|
|
32
|
+
const mins = Math.floor((totalDuration % 3600) / 60);
|
|
33
|
+
durationStr = `${hours}h ${mins}m`;
|
|
34
|
+
} else if (totalDuration >= 60) {
|
|
35
|
+
const mins = Math.floor(totalDuration / 60);
|
|
36
|
+
const secs = Math.floor(totalDuration % 60);
|
|
37
|
+
durationStr = `${mins}m ${secs}s`;
|
|
38
|
+
} else {
|
|
39
|
+
durationStr = `${totalDuration.toFixed(1)}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.push(`- **Total duration:** ${durationStr}`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
|
|
45
|
+
// Job details table
|
|
46
|
+
lines.push("## Job Results");
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push("| # | Status | Duration | Task |");
|
|
49
|
+
lines.push("|---|--------|----------|------|");
|
|
50
|
+
|
|
51
|
+
const statusEmoji: Record<string, string> = {
|
|
52
|
+
success: "✅",
|
|
53
|
+
failed: "❌",
|
|
54
|
+
timeout: "⏱️",
|
|
55
|
+
stalled: "🔄",
|
|
56
|
+
verification_failed: "⚠️",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
results.forEach((r, i) => {
|
|
60
|
+
let taskPreview = r.task.slice(0, 50).replace(/\n/g, " ").trim();
|
|
61
|
+
if (r.task.length > 50) taskPreview += "...";
|
|
62
|
+
const emoji = statusEmoji[r.status] ?? "❓";
|
|
63
|
+
lines.push(
|
|
64
|
+
`| ${i + 1} | ${emoji} ${r.status} | ${r.duration_seconds.toFixed(1)}s | ${taskPreview} |`
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
lines.push("");
|
|
69
|
+
|
|
70
|
+
// Failed jobs details
|
|
71
|
+
const failures = results.filter((r) => r.status !== "success");
|
|
72
|
+
if (failures.length > 0) {
|
|
73
|
+
lines.push("## Failed Jobs");
|
|
74
|
+
lines.push("");
|
|
75
|
+
|
|
76
|
+
failures.forEach((r, i) => {
|
|
77
|
+
const taskPreview = r.task.slice(0, 80).replace(/\n/g, " ").trim();
|
|
78
|
+
lines.push(`### ${i + 1}. ${taskPreview}`);
|
|
79
|
+
lines.push("");
|
|
80
|
+
lines.push(`- **Status:** ${r.status}`);
|
|
81
|
+
if (r.error) {
|
|
82
|
+
lines.push(`- **Error:** ${r.error.slice(0, 200)}`);
|
|
83
|
+
}
|
|
84
|
+
if (r.retries > 0) {
|
|
85
|
+
lines.push(`- **Retries:** ${r.retries}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push("");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Next steps
|
|
92
|
+
lines.push("## Next Steps");
|
|
93
|
+
lines.push("");
|
|
94
|
+
if (failed === 0) {
|
|
95
|
+
lines.push("All jobs completed successfully! No action needed.");
|
|
96
|
+
} else {
|
|
97
|
+
lines.push("The following jobs need attention:");
|
|
98
|
+
lines.push("");
|
|
99
|
+
results.forEach((r, i) => {
|
|
100
|
+
if (r.status !== "success") {
|
|
101
|
+
const taskPreview = r.task.slice(0, 60).replace(/\n/g, " ").trim();
|
|
102
|
+
lines.push(`- [ ] Job ${i + 1}: ${taskPreview} (${r.status})`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
|
|
108
|
+
const content = lines.join("\n");
|
|
109
|
+
|
|
110
|
+
if (outputPath) {
|
|
111
|
+
writeFileSync(outputPath, content);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return content;
|
|
115
|
+
}
|