Spaces:
Running
Running
Merge pull request #97 from huggingface/feat/cron-jobs
Browse files- README.md +2 -1
- docs/cron-jobs.md +118 -0
- server/package-lock.json +22 -0
- server/package.json +1 -0
- server/src/crons.js +236 -0
- server/src/index.js +167 -0
- server/test/cron-api.test.mjs +139 -0
- server/test/crons.test.mjs +113 -0
- web/src/App.tsx +1 -1
- web/src/api.ts +32 -0
- web/src/components/CronSettings.tsx +322 -0
- web/src/components/SettingsView.tsx +11 -1
- web/src/styles.css +62 -0
- web/test/cronSettings.test.mjs +240 -0
README.md
CHANGED
|
@@ -108,13 +108,14 @@ api.set_space_volumes(
|
|
| 108 |
api.restart_space(space_id)
|
| 109 |
```
|
| 110 |
|
| 111 |
-
Everything durable lives under `/data`: `sessions.json`, `groups.json`,
|
| 112 |
`workspaces/<path>/` (agent working dirs + shared `skills/`), and each
|
| 113 |
CLI's closed state checkpoints under `/data/state`. Active harness state lives
|
| 114 |
on local POSIX storage and is restored/checkpointed by
|
| 115 |
[`scripts/agent-state.sh`](scripts/agent-state.sh); SQLite harnesses use online
|
| 116 |
database backups rather than copying live WAL files. See
|
| 117 |
[`docs/agent-state-checkpoints.md`](docs/agent-state-checkpoints.md).
|
|
|
|
| 118 |
|
| 119 |
## Architecture
|
| 120 |
|
|
|
|
| 108 |
api.restart_space(space_id)
|
| 109 |
```
|
| 110 |
|
| 111 |
+
Everything durable lives under `/data`: `sessions.json`, `groups.json`, `crons.json`,
|
| 112 |
`workspaces/<path>/` (agent working dirs + shared `skills/`), and each
|
| 113 |
CLI's closed state checkpoints under `/data/state`. Active harness state lives
|
| 114 |
on local POSIX storage and is restored/checkpointed by
|
| 115 |
[`scripts/agent-state.sh`](scripts/agent-state.sh); SQLite harnesses use online
|
| 116 |
database backups rather than copying live WAL files. See
|
| 117 |
[`docs/agent-state-checkpoints.md`](docs/agent-state-checkpoints.md).
|
| 118 |
+
Scheduled prompts are documented in [`docs/cron-jobs.md`](docs/cron-jobs.md).
|
| 119 |
|
| 120 |
## Architecture
|
| 121 |
|
docs/cron-jobs.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cron jobs
|
| 2 |
+
|
| 3 |
+
Cron jobs send ordinary Agent Manager prompts on a five-field cron schedule.
|
| 4 |
+
They are durable configuration, not terminal processes: definitions and their
|
| 5 |
+
last delivery result live in `DATA_DIR/crons.json` (the mounted bucket in a
|
| 6 |
+
Space), while timers exist only in the current server process.
|
| 7 |
+
|
| 8 |
+
The same feature is available in **Settings → Cron** and over HTTP. As with
|
| 9 |
+
other mutating APIs, an agent passes `?from=$AM_ID`; the browser supplies the
|
| 10 |
+
operator origin automatically.
|
| 11 |
+
|
| 12 |
+
## Create and list
|
| 13 |
+
|
| 14 |
+
```sh
|
| 15 |
+
curl -sS --fail -X POST \
|
| 16 |
+
"http://localhost:${AM_PORT:-7860}/api/crons?from=$AM_ID" \
|
| 17 |
+
-H 'content-type: application/json' -d '{
|
| 18 |
+
"name": "nightly deploy check",
|
| 19 |
+
"agent": { "name": "nightly-index", "cli": "claude" },
|
| 20 |
+
"prompt": "Check last night’s deploy log and report regressions.",
|
| 21 |
+
"schedule": { "cron": "0 9 * * *", "tz": "Europe/Zurich" },
|
| 22 |
+
"runOnRestart": true
|
| 23 |
+
}'
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
The response is the stored job, including a generated `cron_…` id, `state`
|
| 27 |
+
(`running` initially), and `next` as an ISO UTC instant. `schedule.tz` is a
|
| 28 |
+
required IANA timezone; it is applied when calculating occurrences, including
|
| 29 |
+
daylight-saving transitions. Expressions use exactly the conventional five
|
| 30 |
+
fields: minute, hour, day of month, month, day of week.
|
| 31 |
+
|
| 32 |
+
```sh
|
| 33 |
+
curl -s "http://localhost:${AM_PORT:-7860}/api/crons" | jq .crons
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
Each listed job includes:
|
| 37 |
+
|
| 38 |
+
```json
|
| 39 |
+
{
|
| 40 |
+
"id": "cron_7f3a…",
|
| 41 |
+
"name": "nightly deploy check",
|
| 42 |
+
"agent": { "name": "nightly-index", "cli": "claude" },
|
| 43 |
+
"prompt": "…",
|
| 44 |
+
"schedule": { "cron": "0 9 * * *", "tz": "Europe/Zurich" },
|
| 45 |
+
"runOnRestart": true,
|
| 46 |
+
"state": "running",
|
| 47 |
+
"next": "2026-08-20T07:00:00.000Z",
|
| 48 |
+
"last": {
|
| 49 |
+
"at": "2026-08-19T07:00:00.000Z",
|
| 50 |
+
"status": "ok",
|
| 51 |
+
"durationMs": 83,
|
| 52 |
+
"trigger": "schedule"
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
`last.status` reports whether Agent Manager delivered the prompt, or why it
|
| 58 |
+
could not (for example, a CLI no longer installed on the Space). Its duration
|
| 59 |
+
is delivery time, **not the agent task's runtime**. The supported CLIs do not
|
| 60 |
+
provide one shared, trustworthy task-success signal, and an agent can accept
|
| 61 |
+
overlapping prompts, so claiming task success here would be false.
|
| 62 |
+
|
| 63 |
+
## Run, edit, stop, and delete
|
| 64 |
+
|
| 65 |
+
```sh
|
| 66 |
+
# Fire outside the schedule. 202 means accepted for delivery.
|
| 67 |
+
curl -sS --fail -X POST \
|
| 68 |
+
"http://localhost:${AM_PORT:-7860}/api/crons/$ID/run?from=$AM_ID"
|
| 69 |
+
|
| 70 |
+
# Stop keeps the definition and last result, but removes its next occurrence.
|
| 71 |
+
curl -sS --fail -X PUT \
|
| 72 |
+
"http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID" \
|
| 73 |
+
-H 'content-type: application/json' -d '{"state":"stopped"}'
|
| 74 |
+
|
| 75 |
+
# Start again. PUT also accepts any of the create fields for editing.
|
| 76 |
+
curl -sS --fail -X PUT \
|
| 77 |
+
"http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID" \
|
| 78 |
+
-H 'content-type: application/json' -d '{"state":"running"}'
|
| 79 |
+
|
| 80 |
+
curl -sS --fail -X DELETE \
|
| 81 |
+
"http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID"
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Manual Run now works for stopped jobs too; stopped governs future schedule
|
| 85 |
+
fires, not whether the definition may be invoked explicitly.
|
| 86 |
+
|
| 87 |
+
## Agent creation and identity
|
| 88 |
+
|
| 89 |
+
At fire time Agent Manager looks for an existing session with the exact agent
|
| 90 |
+
name. If found, it reuses that session; the job's `agent.cli` is only the type
|
| 91 |
+
to use when creation is needed. If absent, the server synchronously creates one
|
| 92 |
+
session in `workspaces/<slugged-agent-name>` before prompt delivery. Because
|
| 93 |
+
lookup and creation happen without an asynchronous gap, two jobs firing for the
|
| 94 |
+
same missing name cannot create two agents in this server process.
|
| 95 |
+
|
| 96 |
+
The target sees a normal prompt prefixed with:
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
[message from cron "nightly deploy check":]
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Scheduled and restart fires enter the durable operations log with origin
|
| 103 |
+
`{id: "cron:<job-id>", type: "cron", name: "<job-name>"}`. API calls that
|
| 104 |
+
create, edit, manually run, or delete jobs retain the operator/agent identity
|
| 105 |
+
that made the call.
|
| 106 |
+
|
| 107 |
+
## Restart and overlap semantics
|
| 108 |
+
|
| 109 |
+
On server start, every running job gets a newly calculated future occurrence.
|
| 110 |
+
Persisted times that passed while the Space was asleep are not replayed. A
|
| 111 |
+
running job with `runOnRestart: true` also fires once shortly after the server
|
| 112 |
+
starts. If its next scheduled occurrence falls in that same short startup
|
| 113 |
+
window, the scheduled occurrence substitutes for the restart fire so one boot
|
| 114 |
+
cannot send the prompt twice. Stopped jobs do not run on restart.
|
| 115 |
+
|
| 116 |
+
There is deliberately no overlap guard and no spend ceiling. If a target is
|
| 117 |
+
already working, another prompt may land in its input and be handled mid-task.
|
| 118 |
+
Use Stop or Delete when a schedule should no longer spend tokens.
|
server/package-lock.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"license": "Apache-2.0",
|
| 11 |
"dependencies": {
|
| 12 |
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
|
|
|
| 13 |
"express": "^4.19.2",
|
| 14 |
"node-pty": "^1.0.0",
|
| 15 |
"web-push": "^3.6.7",
|
|
@@ -206,6 +207,18 @@
|
|
| 206 |
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
| 207 |
"license": "MIT"
|
| 208 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
"node_modules/debug": {
|
| 210 |
"version": "2.6.9",
|
| 211 |
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
@@ -609,6 +622,15 @@
|
|
| 609 |
"safe-buffer": "^5.0.1"
|
| 610 |
}
|
| 611 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
"node_modules/math-intrinsics": {
|
| 613 |
"version": "1.1.0",
|
| 614 |
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
|
|
| 10 |
"license": "Apache-2.0",
|
| 11 |
"dependencies": {
|
| 12 |
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
| 13 |
+
"cron-parser": "^5.10.0",
|
| 14 |
"express": "^4.19.2",
|
| 15 |
"node-pty": "^1.0.0",
|
| 16 |
"web-push": "^3.6.7",
|
|
|
|
| 207 |
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
| 208 |
"license": "MIT"
|
| 209 |
},
|
| 210 |
+
"node_modules/cron-parser": {
|
| 211 |
+
"version": "5.10.0",
|
| 212 |
+
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.0.tgz",
|
| 213 |
+
"integrity": "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==",
|
| 214 |
+
"license": "MIT",
|
| 215 |
+
"dependencies": {
|
| 216 |
+
"luxon": "^3.7.2"
|
| 217 |
+
},
|
| 218 |
+
"engines": {
|
| 219 |
+
"node": ">=18"
|
| 220 |
+
}
|
| 221 |
+
},
|
| 222 |
"node_modules/debug": {
|
| 223 |
"version": "2.6.9",
|
| 224 |
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
|
|
|
| 622 |
"safe-buffer": "^5.0.1"
|
| 623 |
}
|
| 624 |
},
|
| 625 |
+
"node_modules/luxon": {
|
| 626 |
+
"version": "3.7.2",
|
| 627 |
+
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
| 628 |
+
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
| 629 |
+
"license": "MIT",
|
| 630 |
+
"engines": {
|
| 631 |
+
"node": ">=12"
|
| 632 |
+
}
|
| 633 |
+
},
|
| 634 |
"node_modules/math-intrinsics": {
|
| 635 |
"version": "1.1.0",
|
| 636 |
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
server/package.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
| 23 |
},
|
| 24 |
"dependencies": {
|
| 25 |
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
|
|
|
| 26 |
"express": "^4.19.2",
|
| 27 |
"node-pty": "^1.0.0",
|
| 28 |
"web-push": "^3.6.7",
|
|
|
|
| 23 |
},
|
| 24 |
"dependencies": {
|
| 25 |
"@coder/libghostty-vt-node": "0.1.0-beta.0",
|
| 26 |
+
"cron-parser": "^5.10.0",
|
| 27 |
"express": "^4.19.2",
|
| 28 |
"node-pty": "^1.0.0",
|
| 29 |
"web-push": "^3.6.7",
|
server/src/crons.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import crypto from 'node:crypto';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import path from 'node:path';
|
| 4 |
+
import { CronExpressionParser } from 'cron-parser';
|
| 5 |
+
import { DATA_DIR } from './config.js';
|
| 6 |
+
|
| 7 |
+
export const CRONS_FILE = path.join(DATA_DIR, 'crons.json');
|
| 8 |
+
const MAX_TIMER_MS = 2_147_000_000;
|
| 9 |
+
const VALID_STATES = new Set(['running', 'stopped']);
|
| 10 |
+
|
| 11 |
+
let jobs = [];
|
| 12 |
+
let fireJob = null;
|
| 13 |
+
const timers = new Map();
|
| 14 |
+
|
| 15 |
+
const cleanText = (value, field, max = 160) => {
|
| 16 |
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} required`);
|
| 17 |
+
const text = value.trim();
|
| 18 |
+
if (text.length > max) throw new Error(`${field} is too long (max ${max} characters)`);
|
| 19 |
+
return text;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
| 23 |
+
|
| 24 |
+
function persist() {
|
| 25 |
+
try {
|
| 26 |
+
fs.mkdirSync(path.dirname(CRONS_FILE), { recursive: true });
|
| 27 |
+
const tmp = `${CRONS_FILE}.tmp`;
|
| 28 |
+
fs.writeFileSync(tmp, JSON.stringify(jobs, null, 2), { mode: 0o600 });
|
| 29 |
+
fs.renameSync(tmp, CRONS_FILE);
|
| 30 |
+
} catch (e) {
|
| 31 |
+
// A transient bucket/FUSE write must not take down the process that owns
|
| 32 |
+
// every live terminal. Match the session store's failure posture.
|
| 33 |
+
console.error('[crons.persist]', e && e.message);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function validateSchedule(value, id = '') {
|
| 38 |
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('schedule required');
|
| 39 |
+
const cron = cleanText(value.cron, 'schedule.cron', 120).replace(/\s+/g, ' ');
|
| 40 |
+
if (cron.split(' ').length !== 5) throw new Error('schedule.cron must use the standard five fields: minute hour day month weekday');
|
| 41 |
+
const tz = cleanText(value.tz, 'schedule.tz', 100);
|
| 42 |
+
try {
|
| 43 |
+
// Intl is the runtime authority for IANA zone names; cron-parser then
|
| 44 |
+
// applies that zone (including DST) when it advances the expression.
|
| 45 |
+
new Intl.DateTimeFormat('en', { timeZone: tz }).format(new Date());
|
| 46 |
+
CronExpressionParser.parse(cron, { tz, hashSeed: id || 'agent-manager-cron' }).next();
|
| 47 |
+
} catch (e) {
|
| 48 |
+
throw new Error(`invalid schedule: ${e && e.message ? e.message : e}`);
|
| 49 |
+
}
|
| 50 |
+
return { cron, tz };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function nextOccurrence(schedule, after = new Date(), id = '') {
|
| 54 |
+
const valid = validateSchedule(schedule, id);
|
| 55 |
+
return CronExpressionParser.parse(valid.cron, {
|
| 56 |
+
currentDate: after,
|
| 57 |
+
tz: valid.tz,
|
| 58 |
+
hashSeed: id || 'agent-manager-cron',
|
| 59 |
+
}).next().toISOString();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function normalizeInput(input, existing = null) {
|
| 63 |
+
const src = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
| 64 |
+
const merged = existing ? {
|
| 65 |
+
...existing,
|
| 66 |
+
...src,
|
| 67 |
+
agent: src.agent === undefined ? existing.agent : src.agent,
|
| 68 |
+
schedule: src.schedule === undefined ? existing.schedule : src.schedule,
|
| 69 |
+
} : src;
|
| 70 |
+
const agent = merged.agent;
|
| 71 |
+
if (!agent || typeof agent !== 'object' || Array.isArray(agent)) throw new Error('agent required');
|
| 72 |
+
const state = merged.state === undefined ? 'running' : merged.state;
|
| 73 |
+
if (!VALID_STATES.has(state)) throw new Error("state must be 'running' or 'stopped'");
|
| 74 |
+
const id = existing?.id || `cron_${crypto.randomBytes(5).toString('hex')}`;
|
| 75 |
+
return {
|
| 76 |
+
...(existing || {}),
|
| 77 |
+
id,
|
| 78 |
+
name: cleanText(merged.name, 'name'),
|
| 79 |
+
agent: {
|
| 80 |
+
name: cleanText(agent.name, 'agent.name'),
|
| 81 |
+
cli: cleanText(agent.cli, 'agent.cli', 64),
|
| 82 |
+
},
|
| 83 |
+
prompt: cleanText(merged.prompt, 'prompt', 100_000),
|
| 84 |
+
schedule: validateSchedule(merged.schedule, id),
|
| 85 |
+
runOnRestart: merged.runOnRestart === true,
|
| 86 |
+
state,
|
| 87 |
+
};
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function clearTimer(id) {
|
| 91 |
+
const timer = timers.get(id);
|
| 92 |
+
if (timer) clearTimeout(timer);
|
| 93 |
+
timers.delete(id);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function armExisting(job) {
|
| 97 |
+
clearTimer(job.id);
|
| 98 |
+
if (!fireJob || job.state !== 'running' || !job.next) return;
|
| 99 |
+
const target = Date.parse(job.next);
|
| 100 |
+
if (!Number.isFinite(target)) return;
|
| 101 |
+
const delay = target - Date.now();
|
| 102 |
+
const timer = setTimeout(() => {
|
| 103 |
+
timers.delete(job.id);
|
| 104 |
+
const current = jobs.find((candidate) => candidate.id === job.id);
|
| 105 |
+
if (!current || current.state !== 'running' || current.next !== job.next) return;
|
| 106 |
+
if (Date.now() + 250 < target) {
|
| 107 |
+
armExisting(current);
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
// Advance before dispatch. If delivery is slow, fails, or overlaps another
|
| 111 |
+
// run, this occurrence is still consumed exactly once. Computing from now
|
| 112 |
+
// deliberately skips times missed while the process was unavailable.
|
| 113 |
+
current.next = nextOccurrence(current.schedule, new Date(Math.max(Date.now(), target)), current.id);
|
| 114 |
+
persist();
|
| 115 |
+
armExisting(current);
|
| 116 |
+
Promise.resolve(fireJob(current.id, 'schedule')).catch((e) =>
|
| 117 |
+
console.error('[crons.fire]', current.id, e && e.message));
|
| 118 |
+
}, Math.max(0, Math.min(MAX_TIMER_MS, delay)));
|
| 119 |
+
timer.unref?.();
|
| 120 |
+
timers.set(job.id, timer);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function resetNext(job, now = new Date()) {
|
| 124 |
+
job.next = job.state === 'running' ? nextOccurrence(job.schedule, now, job.id) : null;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export function init(now = new Date()) {
|
| 128 |
+
for (const id of timers.keys()) clearTimer(id);
|
| 129 |
+
fireJob = null;
|
| 130 |
+
try {
|
| 131 |
+
const parsed = JSON.parse(fs.readFileSync(CRONS_FILE, 'utf8'));
|
| 132 |
+
jobs = Array.isArray(parsed) ? parsed : [];
|
| 133 |
+
} catch {
|
| 134 |
+
jobs = [];
|
| 135 |
+
}
|
| 136 |
+
const valid = [];
|
| 137 |
+
for (const raw of jobs) {
|
| 138 |
+
try {
|
| 139 |
+
const job = normalizeInput(raw, raw && raw.id ? raw : null);
|
| 140 |
+
job.createdAt = raw.createdAt || now.toISOString();
|
| 141 |
+
job.updatedAt = raw.updatedAt || job.createdAt;
|
| 142 |
+
if (raw.last && typeof raw.last === 'object') job.last = raw.last;
|
| 143 |
+
resetNext(job, now); // stale persisted times are never replayed
|
| 144 |
+
valid.push(job);
|
| 145 |
+
} catch (e) {
|
| 146 |
+
console.error('[crons.load]', raw && raw.id, e && e.message);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
jobs = valid;
|
| 150 |
+
persist();
|
| 151 |
+
return list();
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export function list() {
|
| 155 |
+
return jobs.map(clone);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export function get(id) {
|
| 159 |
+
const job = jobs.find((candidate) => candidate.id === id);
|
| 160 |
+
return job ? clone(job) : null;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export function create(input, now = new Date()) {
|
| 164 |
+
const job = normalizeInput(input);
|
| 165 |
+
job.createdAt = now.toISOString();
|
| 166 |
+
job.updatedAt = job.createdAt;
|
| 167 |
+
resetNext(job, now);
|
| 168 |
+
jobs.push(job);
|
| 169 |
+
persist();
|
| 170 |
+
armExisting(job);
|
| 171 |
+
return clone(job);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export function update(id, patch, now = new Date()) {
|
| 175 |
+
const index = jobs.findIndex((job) => job.id === id);
|
| 176 |
+
if (index < 0) return null;
|
| 177 |
+
const before = jobs[index];
|
| 178 |
+
const job = normalizeInput(patch, before);
|
| 179 |
+
job.updatedAt = now.toISOString();
|
| 180 |
+
const scheduleChanged = job.schedule.cron !== before.schedule.cron || job.schedule.tz !== before.schedule.tz;
|
| 181 |
+
const resumed = before.state !== 'running' && job.state === 'running';
|
| 182 |
+
if (job.state !== 'running') job.next = null;
|
| 183 |
+
else if (scheduleChanged || resumed || !before.next) resetNext(job, now);
|
| 184 |
+
jobs[index] = job;
|
| 185 |
+
persist();
|
| 186 |
+
armExisting(job);
|
| 187 |
+
return clone(job);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export function remove(id) {
|
| 191 |
+
const before = jobs.length;
|
| 192 |
+
jobs = jobs.filter((job) => job.id !== id);
|
| 193 |
+
if (jobs.length === before) return false;
|
| 194 |
+
clearTimer(id);
|
| 195 |
+
persist();
|
| 196 |
+
return true;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export function recordLast(id, last) {
|
| 200 |
+
const job = jobs.find((candidate) => candidate.id === id);
|
| 201 |
+
if (!job) return null; // deleting a firing job must not recreate it
|
| 202 |
+
// Overlap is allowed. Completion order therefore need not be start order;
|
| 203 |
+
// never let an older, slower delivery replace the genuinely latest fire.
|
| 204 |
+
if (job.last && Date.parse(job.last.at) > Date.parse(last.at)) return clone(job);
|
| 205 |
+
job.last = clone(last);
|
| 206 |
+
persist();
|
| 207 |
+
return clone(job);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
export function startScheduler(handler, { restartDelayMs = 1_500 } = {}) {
|
| 211 |
+
fireJob = handler;
|
| 212 |
+
const restartAt = Date.now() + restartDelayMs;
|
| 213 |
+
// Capture the boot-time occurrence before arming it. By the restart callback
|
| 214 |
+
// it may already have fired and advanced `next`, which would hide the very
|
| 215 |
+
// collision this check prevents.
|
| 216 |
+
const restartJobs = jobs
|
| 217 |
+
.filter((job) => job.state === 'running' && job.runOnRestart)
|
| 218 |
+
.map((job) => ({ id: job.id, scheduledAt: Date.parse(job.next) }));
|
| 219 |
+
for (const job of jobs) armExisting(job);
|
| 220 |
+
if (restartJobs.length) {
|
| 221 |
+
const timer = setTimeout(() => {
|
| 222 |
+
for (const { id, scheduledAt } of restartJobs) {
|
| 223 |
+
const current = jobs.find((job) => job.id === id);
|
| 224 |
+
if (!current || current.state !== 'running' || !current.runOnRestart) continue;
|
| 225 |
+
// One boot intent must not become two prompts. A scheduled occurrence
|
| 226 |
+
// within one restart-delay of the planned restart fire substitutes for
|
| 227 |
+
// it; this is startup de-duplication, not an overlap guard for ordinary
|
| 228 |
+
// runs. Use the captured time so this still holds if schedule fired first.
|
| 229 |
+
if (Number.isFinite(scheduledAt) && Math.abs(scheduledAt - restartAt) <= restartDelayMs) continue;
|
| 230 |
+
Promise.resolve(fireJob(id, 'restart')).catch((e) =>
|
| 231 |
+
console.error('[crons.restart]', id, e && e.message));
|
| 232 |
+
}
|
| 233 |
+
}, restartDelayMs);
|
| 234 |
+
timer.unref?.();
|
| 235 |
+
}
|
| 236 |
+
}
|
server/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import * as groups from './groups.js';
|
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
import * as hidden from './hidden.js';
|
|
|
|
| 20 |
import {
|
| 21 |
attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning,
|
| 22 |
waitForInputReady, capturePane, ghosttyReady, ghosttyError,
|
|
@@ -52,6 +53,7 @@ installSlowFsProbe();
|
|
| 52 |
ensureDirs();
|
| 53 |
refreshVersions();
|
| 54 |
store.init();
|
|
|
|
| 55 |
pruneAttachmentDirs(store.list().map((session) => session.id))
|
| 56 |
.catch((e) => console.error('[attachments.prune]', e && e.message));
|
| 57 |
groups.init();
|
|
@@ -210,6 +212,10 @@ const resolveOperationOrigin = (raw, req) => {
|
|
| 210 |
return { id: 'operator', type: 'operator', name: process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || 'operator' };
|
| 211 |
}
|
| 212 |
if (raw) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
const session = store.get(raw);
|
| 214 |
if (session) return { id: session.id, type: 'agent', name: session.name, cli: session.cli };
|
| 215 |
if (raw.startsWith('remote:')) {
|
|
@@ -1284,6 +1290,33 @@ To reconstruct recent manager operations (newest first):
|
|
| 1284 |
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/operations?limit=100" | jq .operations
|
| 1285 |
\`\`\`
|
| 1286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1287 |
Your session exports the port as \`$AM_PORT\` — read it rather than trusting a
|
| 1288 |
number you remember, and check it before you believe an empty answer: \`curl -s\`
|
| 1289 |
to a port nothing is listening on prints **nothing at all**, and in some agent
|
|
@@ -2207,6 +2240,128 @@ app.post('/api/sessions', (req, res) => {
|
|
| 2207 |
res.status(201).json({ ...s, running: false, state: 'stopped' });
|
| 2208 |
});
|
| 2209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2210 |
// Rename = display label only. Folders are never renamed or moved.
|
| 2211 |
app.put('/api/sessions/:id', (req, res) => {
|
| 2212 |
const name = (req.body || {}).name;
|
|
@@ -2789,4 +2944,16 @@ server.listen(PORT, () => {
|
|
| 2789 |
console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
|
| 2790 |
console.log('⚠ No authentication: this app trusts whoever can reach it.');
|
| 2791 |
console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2792 |
});
|
|
|
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
import * as hidden from './hidden.js';
|
| 20 |
+
import * as crons from './crons.js';
|
| 21 |
import {
|
| 22 |
attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning,
|
| 23 |
waitForInputReady, capturePane, ghosttyReady, ghosttyError,
|
|
|
|
| 53 |
ensureDirs();
|
| 54 |
refreshVersions();
|
| 55 |
store.init();
|
| 56 |
+
crons.init();
|
| 57 |
pruneAttachmentDirs(store.list().map((session) => session.id))
|
| 58 |
.catch((e) => console.error('[attachments.prune]', e && e.message));
|
| 59 |
groups.init();
|
|
|
|
| 212 |
return { id: 'operator', type: 'operator', name: process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || 'operator' };
|
| 213 |
}
|
| 214 |
if (raw) {
|
| 215 |
+
if (raw.startsWith('cron:')) {
|
| 216 |
+
const job = crons.get(raw.slice('cron:'.length));
|
| 217 |
+
if (job) return { id: raw, type: 'cron', name: job.name };
|
| 218 |
+
}
|
| 219 |
const session = store.get(raw);
|
| 220 |
if (session) return { id: session.id, type: 'agent', name: session.name, cli: session.cli };
|
| 221 |
if (raw.startsWith('remote:')) {
|
|
|
|
| 1290 |
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/operations?limit=100" | jq .operations
|
| 1291 |
\`\`\`
|
| 1292 |
|
| 1293 |
+
### Schedule recurring prompts
|
| 1294 |
+
A cron job sends a normal prompt on a five-field cron schedule. It survives
|
| 1295 |
+
Space restarts in durable storage; if the named agent does not exist when it
|
| 1296 |
+
fires, the manager creates it in \`workspaces/<agent-name>\` first. The timezone
|
| 1297 |
+
is required because the Space clock is UTC. Create one with your own id so the
|
| 1298 |
+
operation log records who asked:
|
| 1299 |
+
|
| 1300 |
+
\`\`\`sh
|
| 1301 |
+
curl -sS --fail -X POST "http://localhost:\${AM_PORT:-${PORT}}/api/crons?from=$AM_ID" \\
|
| 1302 |
+
-H 'content-type: application/json' -d '{
|
| 1303 |
+
"name":"weekday issue triage",
|
| 1304 |
+
"agent":{"name":"triage","cli":"claude"},
|
| 1305 |
+
"prompt":"Triage newly opened issues and report anything urgent.",
|
| 1306 |
+
"schedule":{"cron":"0 9 * * 1-5","tz":"Europe/Zurich"},
|
| 1307 |
+
"runOnRestart":true
|
| 1308 |
+
}'
|
| 1309 |
+
curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/crons" | jq .crons
|
| 1310 |
+
\`\`\`
|
| 1311 |
+
|
| 1312 |
+
Use \`POST /api/crons/$ID/run?from=$AM_ID\` to run now,
|
| 1313 |
+
\`PUT /api/crons/$ID?from=$AM_ID\` with \`{"state":"stopped"}\` (or
|
| 1314 |
+
\`"running"\`) to stop/start it, and \`DELETE /api/crons/$ID?from=$AM_ID\` to
|
| 1315 |
+
remove it. Run-on-restart is one fresh fire, never a replay of missed times.
|
| 1316 |
+
There is deliberately no overlap or spend guard: stop or delete jobs you no
|
| 1317 |
+
longer want. See \`docs/cron-jobs.md\` in the Agent Manager source for the full
|
| 1318 |
+
request and response shapes.
|
| 1319 |
+
|
| 1320 |
Your session exports the port as \`$AM_PORT\` — read it rather than trusting a
|
| 1321 |
number you remember, and check it before you believe an empty answer: \`curl -s\`
|
| 1322 |
to a port nothing is listening on prints **nothing at all**, and in some agent
|
|
|
|
| 2240 |
res.status(201).json({ ...s, running: false, state: 'stopped' });
|
| 2241 |
});
|
| 2242 |
|
| 2243 |
+
// ---------- scheduled prompts (/api/crons) ----------
|
| 2244 |
+
//
|
| 2245 |
+
// A cron's agent type is only used when its named agent does not exist yet.
|
| 2246 |
+
// Existing names are deliberately reused (even if the session was created with
|
| 2247 |
+
// another CLI): the name is the idempotency key promised by the Settings form.
|
| 2248 |
+
const cronCliError = (cli) => {
|
| 2249 |
+
const def = cliById(cli);
|
| 2250 |
+
if (!def || !isAgentCli(cli) || isRemote(cli)) {
|
| 2251 |
+
return `unknown agent type '${cli}' — use an agent from GET /api/clis (not shell, files, trace, or remote)`;
|
| 2252 |
+
}
|
| 2253 |
+
return null;
|
| 2254 |
+
};
|
| 2255 |
+
const cronAgentFolder = (name) => {
|
| 2256 |
+
const readable = slugify(name);
|
| 2257 |
+
if (readable) return readable;
|
| 2258 |
+
// A perfectly valid display name can contain no ASCII characters. Do not
|
| 2259 |
+
// collapse those agents into the workspaces root (or one shared `agent/`
|
| 2260 |
+
// folder); a tiny deterministic suffix keeps the promised private folder.
|
| 2261 |
+
let hash = 2_166_136_261;
|
| 2262 |
+
for (const character of name) hash = Math.imul(hash ^ character.codePointAt(0), 16_777_619);
|
| 2263 |
+
return `agent-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
| 2264 |
+
};
|
| 2265 |
+
|
| 2266 |
+
function beginCronFire(job, trigger) {
|
| 2267 |
+
const at = new Date();
|
| 2268 |
+
const started = Date.now();
|
| 2269 |
+
const fail = (error) => {
|
| 2270 |
+
const message = String(error && error.message || error).slice(0, 500);
|
| 2271 |
+
crons.recordLast(job.id, {
|
| 2272 |
+
at: at.toISOString(), status: 'failed', durationMs: Date.now() - started, trigger, error: message,
|
| 2273 |
+
});
|
| 2274 |
+
return message;
|
| 2275 |
+
};
|
| 2276 |
+
|
| 2277 |
+
try {
|
| 2278 |
+
let session = store.list().find((candidate) => candidate.name === job.agent.name) || null;
|
| 2279 |
+
let agentCreated = false;
|
| 2280 |
+
if (!session) {
|
| 2281 |
+
const invalid = cronCliError(job.agent.cli);
|
| 2282 |
+
if (invalid) throw new Error(invalid);
|
| 2283 |
+
const catalog = cliCatalog().find((candidate) => candidate.id === job.agent.cli);
|
| 2284 |
+
if (!catalog?.available) throw new Error(`${cliById(job.agent.cli).label} is not installed on this Space`);
|
| 2285 |
+
session = createSession({
|
| 2286 |
+
name: job.agent.name,
|
| 2287 |
+
cli: job.agent.cli,
|
| 2288 |
+
// Cron-created agents own a predictable workspace. A second job with
|
| 2289 |
+
// the same name sees the session synchronously and cannot create it
|
| 2290 |
+
// again, even while the first prompt is still being delivered.
|
| 2291 |
+
path: cronAgentFolder(job.agent.name),
|
| 2292 |
+
});
|
| 2293 |
+
if (!session) throw new Error('could not create the agent workspace');
|
| 2294 |
+
if (session.error) throw new Error(session.error);
|
| 2295 |
+
agentCreated = true;
|
| 2296 |
+
}
|
| 2297 |
+
if (!promptable(session) || isRemote(session.cli)) {
|
| 2298 |
+
throw new Error(`the existing '${session.name}' session (${session.cli}) cannot receive scheduled prompts`);
|
| 2299 |
+
}
|
| 2300 |
+
const text = `[message from cron "${job.name}":] ${job.prompt}`;
|
| 2301 |
+
const completion = deliver(session, { text }, `cron: ${job.name}`)
|
| 2302 |
+
.then(() => {
|
| 2303 |
+
crons.recordLast(job.id, {
|
| 2304 |
+
at: at.toISOString(), status: 'ok', durationMs: Date.now() - started, trigger,
|
| 2305 |
+
});
|
| 2306 |
+
})
|
| 2307 |
+
.catch((error) => { fail(error); });
|
| 2308 |
+
return { agentCreated, completion };
|
| 2309 |
+
} catch (error) {
|
| 2310 |
+
const message = fail(error);
|
| 2311 |
+
throw Object.assign(new Error(message), { statusCode: 409 });
|
| 2312 |
+
}
|
| 2313 |
+
}
|
| 2314 |
+
|
| 2315 |
+
const validateCronCli = (body) => {
|
| 2316 |
+
const cli = body?.agent?.cli;
|
| 2317 |
+
return typeof cli === 'string' ? cronCliError(cli.trim()) : null;
|
| 2318 |
+
};
|
| 2319 |
+
|
| 2320 |
+
app.get('/api/crons', (_req, res) => res.json({ crons: crons.list() }));
|
| 2321 |
+
|
| 2322 |
+
app.post('/api/crons', (req, res) => {
|
| 2323 |
+
const cliError = validateCronCli(req.body);
|
| 2324 |
+
if (cliError) return res.status(400).json({ error: cliError });
|
| 2325 |
+
try {
|
| 2326 |
+
const job = crons.create(req.body || {});
|
| 2327 |
+
return res.status(201).json(job);
|
| 2328 |
+
} catch (e) {
|
| 2329 |
+
return res.status(400).json({ error: String(e.message || e) });
|
| 2330 |
+
}
|
| 2331 |
+
});
|
| 2332 |
+
|
| 2333 |
+
app.put('/api/crons/:id', (req, res) => {
|
| 2334 |
+
if (!crons.get(req.params.id)) return res.status(404).json({ error: 'not found' });
|
| 2335 |
+
const cliError = validateCronCli(req.body);
|
| 2336 |
+
if (cliError) return res.status(400).json({ error: cliError });
|
| 2337 |
+
try {
|
| 2338 |
+
return res.json(crons.update(req.params.id, req.body || {}));
|
| 2339 |
+
} catch (e) {
|
| 2340 |
+
return res.status(400).json({ error: String(e.message || e) });
|
| 2341 |
+
}
|
| 2342 |
+
});
|
| 2343 |
+
|
| 2344 |
+
app.delete('/api/crons/:id', (req, res) => {
|
| 2345 |
+
if (!crons.remove(req.params.id)) return res.status(404).json({ error: 'not found' });
|
| 2346 |
+
return res.json({ ok: true });
|
| 2347 |
+
});
|
| 2348 |
+
|
| 2349 |
+
app.post('/api/crons/:id/run', (req, res) => {
|
| 2350 |
+
const job = crons.get(req.params.id);
|
| 2351 |
+
if (!job) return res.status(404).json({ error: 'not found' });
|
| 2352 |
+
const requested = String(req.query.trigger || '');
|
| 2353 |
+
const trigger = req.operationOrigin?.type === 'cron' && (requested === 'schedule' || requested === 'restart')
|
| 2354 |
+
? requested : 'manual';
|
| 2355 |
+
try {
|
| 2356 |
+
const run = beginCronFire(job, trigger);
|
| 2357 |
+
// 202 means the prompt was accepted for delivery, not that the agent's work
|
| 2358 |
+
// has finished. `last` is updated when delivery itself succeeds or fails.
|
| 2359 |
+
return res.status(202).json({ ok: true, agentCreated: run.agentCreated });
|
| 2360 |
+
} catch (e) {
|
| 2361 |
+
return res.status(e.statusCode || 409).json({ error: String(e.message || e) });
|
| 2362 |
+
}
|
| 2363 |
+
});
|
| 2364 |
+
|
| 2365 |
// Rename = display label only. Folders are never renamed or moved.
|
| 2366 |
app.put('/api/sessions/:id', (req, res) => {
|
| 2367 |
const name = (req.body || {}).name;
|
|
|
|
| 2944 |
console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
|
| 2945 |
console.log('⚠ No authentication: this app trusts whoever can reach it.');
|
| 2946 |
console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
|
| 2947 |
+
// Scheduled fires use the public cron-run route too. That keeps one execution
|
| 2948 |
+
// path and gives the operations log a first-class `cron:<id>` origin instead
|
| 2949 |
+
// of inventing a session that does not exist.
|
| 2950 |
+
crons.startScheduler(async (id, trigger) => {
|
| 2951 |
+
const response = await fetch(`http://127.0.0.1:${PORT}/api/crons/${encodeURIComponent(id)}/run?trigger=${trigger}`, {
|
| 2952 |
+
method: 'POST', headers: { 'x-am-origin': `cron:${id}` },
|
| 2953 |
+
});
|
| 2954 |
+
if (!response.ok) {
|
| 2955 |
+
const body = await response.json().catch(() => ({}));
|
| 2956 |
+
throw new Error(body.error || `cron run returned HTTP ${response.status}`);
|
| 2957 |
+
}
|
| 2958 |
+
});
|
| 2959 |
});
|
server/test/cron-api.test.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Cron API, durable boot behavior, scheduled identity, and same-name creation.
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import os from 'node:os';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
import { spawn } from 'node:child_process';
|
| 6 |
+
|
| 7 |
+
const PORT = 7902;
|
| 8 |
+
const API = `http://127.0.0.1:${PORT}`;
|
| 9 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-cron-api-'));
|
| 10 |
+
const bin = path.join(DATA_DIR, 'bin');
|
| 11 |
+
fs.mkdirSync(bin, { recursive: true });
|
| 12 |
+
const fakeClaude = path.join(bin, 'claude');
|
| 13 |
+
fs.writeFileSync(fakeClaude, `#!/bin/sh
|
| 14 |
+
printf '%s\\n' "$*" >> "$DATA_DIR/fake-claude.log"
|
| 15 |
+
sleep 120
|
| 16 |
+
`, { mode: 0o755 });
|
| 17 |
+
|
| 18 |
+
const seed = (id, name) => ({
|
| 19 |
+
id, name,
|
| 20 |
+
agent: { name: 'shared-cron-agent', cli: 'claude' },
|
| 21 |
+
prompt: `Run ${name}.`,
|
| 22 |
+
schedule: { cron: '0 * * * *', tz: 'UTC' },
|
| 23 |
+
runOnRestart: true,
|
| 24 |
+
state: 'running',
|
| 25 |
+
createdAt: '2026-08-19T00:00:00.000Z',
|
| 26 |
+
updatedAt: '2026-08-19T00:00:00.000Z',
|
| 27 |
+
});
|
| 28 |
+
fs.writeFileSync(path.join(DATA_DIR, 'crons.json'), JSON.stringify([
|
| 29 |
+
seed('cron_boot_one', 'boot one'), seed('cron_boot_two', 'boot two'),
|
| 30 |
+
]));
|
| 31 |
+
|
| 32 |
+
let pass = 0; let fail = 0;
|
| 33 |
+
const check = (name, ok, detail = '') => {
|
| 34 |
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 35 |
+
ok ? pass++ : fail++;
|
| 36 |
+
};
|
| 37 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 38 |
+
const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env;
|
| 39 |
+
const server = spawn('node', ['src/index.js'], {
|
| 40 |
+
env: {
|
| 41 |
+
...BASE_ENV,
|
| 42 |
+
PATH: `${bin}:${BASE_ENV.PATH || ''}`,
|
| 43 |
+
PORT: String(PORT), DATA_DIR, HOME: path.join(DATA_DIR, 'home'),
|
| 44 |
+
CLAUDE_CONFIG_DIR: path.join(DATA_DIR, 'home', '.claude'),
|
| 45 |
+
AM_BASHRC: '/nonexistent',
|
| 46 |
+
},
|
| 47 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 48 |
+
});
|
| 49 |
+
let log = '';
|
| 50 |
+
server.stdout.on('data', (chunk) => { log += chunk; });
|
| 51 |
+
server.stderr.on('data', (chunk) => { log += chunk; });
|
| 52 |
+
|
| 53 |
+
const api = async (route, init = {}) => {
|
| 54 |
+
const headers = new Headers(init.headers || {});
|
| 55 |
+
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
| 56 |
+
if (init.method && init.method !== 'GET') headers.set('x-am-origin', 'operator');
|
| 57 |
+
const response = await fetch(`${API}${route}`, { ...init, headers });
|
| 58 |
+
const body = await response.json().catch(() => null);
|
| 59 |
+
return { status: response.status, body };
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
for (let i = 0; i < 80; i++) {
|
| 64 |
+
if (await fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false)) break;
|
| 65 |
+
await sleep(250);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Both boot jobs fire, but name lookup + synchronous creation gives them one
|
| 69 |
+
// shared agent. This is the race the feature must be idempotent across.
|
| 70 |
+
let sessions = [];
|
| 71 |
+
let operations = [];
|
| 72 |
+
for (let i = 0; i < 60; i++) {
|
| 73 |
+
sessions = (await api('/api/sessions')).body || [];
|
| 74 |
+
operations = (await api('/api/operations?limit=50')).body?.operations || [];
|
| 75 |
+
const cronOps = operations.filter((row) => row.origin?.type === 'cron');
|
| 76 |
+
if (sessions.length === 1 && cronOps.length >= 2) break;
|
| 77 |
+
await sleep(250);
|
| 78 |
+
}
|
| 79 |
+
check('two restart jobs naming one agent create exactly one session', sessions.length === 1, `sessions ${sessions.length}`);
|
| 80 |
+
check('the cron-created workspace follows workspaces/<agent-name>', sessions[0]?.path === 'shared-cron-agent', `path ${sessions[0]?.path}`);
|
| 81 |
+
const cronOps = operations.filter((row) => row.origin?.type === 'cron');
|
| 82 |
+
check('scheduled runs are attributed to cron identities in operations',
|
| 83 |
+
cronOps.length >= 2 && cronOps.every((row) => row.origin.id.startsWith('cron:') && row.origin.name.startsWith('boot')),
|
| 84 |
+
JSON.stringify(cronOps.map((row) => row.origin)));
|
| 85 |
+
|
| 86 |
+
let listed = await api('/api/crons');
|
| 87 |
+
for (let i = 0; i < 80 && !listed.body?.crons?.every((job) => job.last); i++) {
|
| 88 |
+
await sleep(250);
|
| 89 |
+
listed = await api('/api/crons');
|
| 90 |
+
}
|
| 91 |
+
check('GET lists both durable jobs', listed.status === 200 && listed.body?.crons?.length === 2);
|
| 92 |
+
check('run-on-restart records delivery outcomes', listed.body.crons.every((job) => job.last?.status === 'ok'),
|
| 93 |
+
JSON.stringify(listed.body.crons.map((job) => job.last)));
|
| 94 |
+
|
| 95 |
+
const manual = await api('/api/crons/cron_boot_one/run', { method: 'POST' });
|
| 96 |
+
check('Run now is accepted and reuses the agent', manual.status === 202 && manual.body?.agentCreated === false,
|
| 97 |
+
JSON.stringify(manual.body));
|
| 98 |
+
|
| 99 |
+
const stopped = await api('/api/crons/cron_boot_one', {
|
| 100 |
+
method: 'PUT', body: JSON.stringify({ state: 'stopped' }),
|
| 101 |
+
});
|
| 102 |
+
check('stop keeps the job and clears its next fire', stopped.status === 200 && stopped.body.state === 'stopped' && stopped.body.next === null);
|
| 103 |
+
|
| 104 |
+
const created = await api('/api/crons', {
|
| 105 |
+
method: 'POST', body: JSON.stringify({
|
| 106 |
+
name: 'Zurich morning', agent: { name: 'morning', cli: 'claude' }, prompt: 'Good morning.',
|
| 107 |
+
schedule: { cron: '0 9 * * 1-5', tz: 'Europe/Zurich' }, runOnRestart: false,
|
| 108 |
+
}),
|
| 109 |
+
});
|
| 110 |
+
check('POST stores the timezone and returns the next UTC instant',
|
| 111 |
+
created.status === 201 && created.body.schedule.tz === 'Europe/Zurich' && /Z$/.test(created.body.next),
|
| 112 |
+
JSON.stringify(created.body));
|
| 113 |
+
|
| 114 |
+
const invalid = await api('/api/crons', {
|
| 115 |
+
method: 'POST', body: JSON.stringify({
|
| 116 |
+
name: 'bad zone', agent: { name: 'bad', cli: 'claude' }, prompt: 'No.',
|
| 117 |
+
schedule: { cron: '0 9 * * *', tz: 'Moon/Sea' },
|
| 118 |
+
}),
|
| 119 |
+
});
|
| 120 |
+
check('invalid timezone is a readable 400', invalid.status === 400 && /invalid schedule/.test(invalid.body?.error || ''), JSON.stringify(invalid.body));
|
| 121 |
+
|
| 122 |
+
const deleted = await api('/api/crons/cron_boot_two', { method: 'DELETE' });
|
| 123 |
+
check('delete is distinct from stop and removes the job', deleted.status === 200
|
| 124 |
+
&& !(await api('/api/crons')).body.crons.some((job) => job.id === 'cron_boot_two'));
|
| 125 |
+
|
| 126 |
+
const disk = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'crons.json'), 'utf8'));
|
| 127 |
+
check('the final state is on DATA_DIR, not local process memory',
|
| 128 |
+
disk.some((job) => job.id === 'cron_boot_one' && job.state === 'stopped')
|
| 129 |
+
&& !disk.some((job) => job.id === 'cron_boot_two'));
|
| 130 |
+
} catch (error) {
|
| 131 |
+
check(`suite threw: ${error && error.message}`, false, log.slice(-1200));
|
| 132 |
+
} finally {
|
| 133 |
+
server.kill('SIGTERM');
|
| 134 |
+
await Promise.race([new Promise((resolve) => server.once('exit', resolve)), sleep(3000)]);
|
| 135 |
+
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 139 |
+
process.exit(fail ? 1 : 0);
|
server/test/crons.test.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import fs from 'node:fs';
|
| 3 |
+
import os from 'node:os';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
import test from 'node:test';
|
| 6 |
+
|
| 7 |
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-crons-unit-'));
|
| 8 |
+
process.env.DATA_DIR = root;
|
| 9 |
+
const crons = await import('../src/crons.js');
|
| 10 |
+
|
| 11 |
+
test.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
| 12 |
+
|
| 13 |
+
test('timezone is part of the calculation, including seasonal offsets', () => {
|
| 14 |
+
const schedule = { cron: '0 9 * * *', tz: 'Europe/Zurich' };
|
| 15 |
+
assert.equal(crons.nextOccurrence(schedule, new Date('2026-08-19T21:30:00Z'), 'daily'), '2026-08-20T07:00:00.000Z');
|
| 16 |
+
assert.equal(crons.nextOccurrence(schedule, new Date('2027-01-02T10:00:00Z'), 'daily'), '2027-01-03T08:00:00.000Z');
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
test('only five-field cron and real IANA timezones are accepted', () => {
|
| 20 |
+
assert.throws(() => crons.validateSchedule({ cron: '0 0 9 * * *', tz: 'UTC' }), /five fields/);
|
| 21 |
+
assert.throws(() => crons.validateSchedule({ cron: '0 9 * * *', tz: 'Moon\/Tranquility' }), /invalid schedule/);
|
| 22 |
+
assert.throws(() => crons.validateSchedule({ cron: '70 9 * * *', tz: 'UTC' }), /invalid schedule/);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
test('jobs persist, stop without deletion, resume from now, and never replay stale next times', () => {
|
| 26 |
+
crons.init(new Date('2026-08-19T12:00:00Z'));
|
| 27 |
+
const job = crons.create({
|
| 28 |
+
name: 'daily index',
|
| 29 |
+
agent: { name: 'indexer', cli: 'claude' },
|
| 30 |
+
prompt: 'Refresh the index.',
|
| 31 |
+
schedule: { cron: '0 9 * * *', tz: 'UTC' },
|
| 32 |
+
runOnRestart: true,
|
| 33 |
+
}, new Date('2026-08-19T12:00:00Z'));
|
| 34 |
+
assert.equal(job.next, '2026-08-20T09:00:00.000Z');
|
| 35 |
+
assert.ok(fs.existsSync(path.join(root, 'crons.json')));
|
| 36 |
+
|
| 37 |
+
const stopped = crons.update(job.id, { state: 'stopped' }, new Date('2026-08-19T13:00:00Z'));
|
| 38 |
+
assert.equal(stopped.next, null);
|
| 39 |
+
assert.equal(stopped.prompt, 'Refresh the index.');
|
| 40 |
+
assert.equal(crons.list().length, 1);
|
| 41 |
+
|
| 42 |
+
const resumed = crons.update(job.id, { state: 'running' }, new Date('2026-08-21T10:00:00Z'));
|
| 43 |
+
assert.equal(resumed.next, '2026-08-22T09:00:00.000Z');
|
| 44 |
+
|
| 45 |
+
// Loading after downtime ignores the persisted Aug 22 occurrence and moves
|
| 46 |
+
// directly to the next future one. Missed occurrences are not catch-up work.
|
| 47 |
+
crons.init(new Date('2026-08-25T10:00:00Z'));
|
| 48 |
+
assert.equal(crons.get(job.id).next, '2026-08-26T09:00:00.000Z');
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
test('run on restart fires once for enabled running jobs, not stopped ones', async () => {
|
| 52 |
+
const running = crons.get(crons.list()[0].id);
|
| 53 |
+
assert.equal(running.runOnRestart, true);
|
| 54 |
+
const stopped = crons.create({
|
| 55 |
+
name: 'off', agent: { name: 'off-agent', cli: 'codex' }, prompt: 'No.',
|
| 56 |
+
schedule: { cron: '0 * * * *', tz: 'UTC' }, runOnRestart: true, state: 'stopped',
|
| 57 |
+
});
|
| 58 |
+
const fired = [];
|
| 59 |
+
crons.startScheduler((id, trigger) => { fired.push({ id, trigger }); }, { restartDelayMs: 5 });
|
| 60 |
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
| 61 |
+
assert.deepEqual(fired, [{ id: running.id, trigger: 'restart' }]);
|
| 62 |
+
assert.equal(crons.get(stopped.id).state, 'stopped');
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
test('an older overlapping delivery cannot overwrite the newer last run', () => {
|
| 66 |
+
const id = crons.list()[0].id;
|
| 67 |
+
crons.recordLast(id, { at: '2026-08-25T12:02:00.000Z', status: 'ok', durationMs: 20 });
|
| 68 |
+
crons.recordLast(id, { at: '2026-08-25T12:01:00.000Z', status: 'failed', durationMs: 80_000, error: 'late failure' });
|
| 69 |
+
assert.equal(crons.get(id).last.status, 'ok');
|
| 70 |
+
assert.equal(crons.get(id).last.at, '2026-08-25T12:02:00.000Z');
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
test('run on restart coalesces with a schedule due in the same startup window', () => {
|
| 74 |
+
for (const job of crons.list()) crons.remove(job.id);
|
| 75 |
+
const boot = Date.parse('2026-08-19T21:54:57.600Z'); // 2.4s before the minute
|
| 76 |
+
crons.init(new Date(boot));
|
| 77 |
+
const job = crons.create({
|
| 78 |
+
name: 'minute boundary', agent: { name: 'boundary', cli: 'claude' }, prompt: 'Once.',
|
| 79 |
+
schedule: { cron: '* * * * *', tz: 'UTC' }, runOnRestart: true,
|
| 80 |
+
}, new Date(boot));
|
| 81 |
+
assert.equal(Date.parse(job.next) - boot, 2_400);
|
| 82 |
+
|
| 83 |
+
const originalNow = Date.now;
|
| 84 |
+
const originalSetTimeout = globalThis.setTimeout;
|
| 85 |
+
const originalClearTimeout = globalThis.clearTimeout;
|
| 86 |
+
let now = boot;
|
| 87 |
+
let serial = 0;
|
| 88 |
+
const queued = [];
|
| 89 |
+
Date.now = () => now;
|
| 90 |
+
globalThis.setTimeout = (callback, delay = 0) => {
|
| 91 |
+
const timer = { id: ++serial, at: now + Number(delay), callback, canceled: false, unref() {} };
|
| 92 |
+
queued.push(timer);
|
| 93 |
+
return timer;
|
| 94 |
+
};
|
| 95 |
+
globalThis.clearTimeout = (timer) => { if (timer) timer.canceled = true; };
|
| 96 |
+
const fires = [];
|
| 97 |
+
try {
|
| 98 |
+
crons.startScheduler((id, trigger) => { fires.push({ id, trigger, at: now }); }, { restartDelayMs: 1_500 });
|
| 99 |
+
const end = boot + 2_500;
|
| 100 |
+
while (true) {
|
| 101 |
+
const due = queued.filter((timer) => !timer.canceled && timer.at <= end).sort((a, b) => a.at - b.at)[0];
|
| 102 |
+
if (!due) break;
|
| 103 |
+
due.canceled = true;
|
| 104 |
+
now = due.at;
|
| 105 |
+
due.callback();
|
| 106 |
+
}
|
| 107 |
+
assert.deepEqual(fires, [{ id: job.id, trigger: 'schedule', at: boot + 2_400 }]);
|
| 108 |
+
} finally {
|
| 109 |
+
Date.now = originalNow;
|
| 110 |
+
globalThis.setTimeout = originalSetTimeout;
|
| 111 |
+
globalThis.clearTimeout = originalClearTimeout;
|
| 112 |
+
}
|
| 113 |
+
});
|
web/src/App.tsx
CHANGED
|
@@ -50,7 +50,7 @@ function autoGrid(n: number): GridSpec {
|
|
| 50 |
return { cols: 3, rows: 3 };
|
| 51 |
}
|
| 52 |
|
| 53 |
-
type SettingsPage = 'general' | 'usage' | 'skills';
|
| 54 |
const ROOT_PATH = '.';
|
| 55 |
const WARM_TERMINAL_LIMIT = 12;
|
| 56 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
|
|
|
| 50 |
return { cols: 3, rows: 3 };
|
| 51 |
}
|
| 52 |
|
| 53 |
+
type SettingsPage = 'general' | 'usage' | 'skills' | 'cron';
|
| 54 |
const ROOT_PATH = '.';
|
| 55 |
const WARM_TERMINAL_LIMIT = 12;
|
| 56 |
const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
|
web/src/api.ts
CHANGED
|
@@ -120,6 +120,38 @@ export const getConfig = (): Promise<AmConfig> => fetch('/api/config').then(json
|
|
| 120 |
export const saveConfig = (c: AmConfig) =>
|
| 121 |
fetch('/api/config', { method: 'PUT', headers: HEADERS, body: JSON.stringify(c) }).then(json);
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
// ---- bucket backup: a Job on the Hub does the copying (docs/bucket-backup.md) ----
|
| 124 |
export type BackupEvery = 'never' | '1h' | '3h' | '24h';
|
| 125 |
export interface BackupStatus {
|
|
|
|
| 120 |
export const saveConfig = (c: AmConfig) =>
|
| 121 |
fetch('/api/config', { method: 'PUT', headers: HEADERS, body: JSON.stringify(c) }).then(json);
|
| 122 |
|
| 123 |
+
// ---- durable scheduled prompts ----
|
| 124 |
+
export type CronState = 'running' | 'stopped';
|
| 125 |
+
export interface CronJob {
|
| 126 |
+
id: string;
|
| 127 |
+
name: string;
|
| 128 |
+
agent: { name: string; cli: string };
|
| 129 |
+
prompt: string;
|
| 130 |
+
schedule: { cron: string; tz: string };
|
| 131 |
+
runOnRestart: boolean;
|
| 132 |
+
state: CronState;
|
| 133 |
+
createdAt: string;
|
| 134 |
+
updatedAt: string;
|
| 135 |
+
next: string | null;
|
| 136 |
+
last?: {
|
| 137 |
+
at: string;
|
| 138 |
+
status: 'ok' | 'failed';
|
| 139 |
+
durationMs: number;
|
| 140 |
+
trigger?: 'schedule' | 'restart' | 'manual';
|
| 141 |
+
error?: string;
|
| 142 |
+
};
|
| 143 |
+
}
|
| 144 |
+
export type CronDraft = Pick<CronJob, 'name' | 'agent' | 'prompt' | 'schedule' | 'runOnRestart'>;
|
| 145 |
+
export const getCrons = (): Promise<{ crons: CronJob[] }> => fetch('/api/crons').then(jsonOrError);
|
| 146 |
+
export const createCron = (job: CronDraft): Promise<CronJob> =>
|
| 147 |
+
fetch('/api/crons', { method: 'POST', headers: HEADERS, body: JSON.stringify(job) }).then(jsonOrError);
|
| 148 |
+
export const updateCron = (id: string, patch: Partial<CronDraft & { state: CronState }>): Promise<CronJob> =>
|
| 149 |
+
fetch(`/api/crons/${encodeURIComponent(id)}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify(patch) }).then(jsonOrError);
|
| 150 |
+
export const runCron = (id: string): Promise<{ ok: boolean; agentCreated: boolean }> =>
|
| 151 |
+
fetch(`/api/crons/${encodeURIComponent(id)}/run`, { method: 'POST' }).then(jsonOrError);
|
| 152 |
+
export const deleteCron = (id: string): Promise<{ ok: boolean }> =>
|
| 153 |
+
fetch(`/api/crons/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(jsonOrError);
|
| 154 |
+
|
| 155 |
// ---- bucket backup: a Job on the Hub does the copying (docs/bucket-backup.md) ----
|
| 156 |
export type BackupEvery = 'never' | '1h' | '3h' | '24h';
|
| 157 |
export interface BackupStatus {
|
web/src/components/CronSettings.tsx
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
|
| 2 |
+
import * as api from '../api';
|
| 3 |
+
import type { Cli, Session } from '../types';
|
| 4 |
+
import Logo from './Logo';
|
| 5 |
+
|
| 6 |
+
type Preset = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'custom';
|
| 7 |
+
const EXCLUDED_CLIS = new Set(['shell', 'files', 'trace', 'remote']);
|
| 8 |
+
const DAYS = [
|
| 9 |
+
{ value: '1', short: 'Mon', label: 'Monday' }, { value: '2', short: 'Tue', label: 'Tuesday' },
|
| 10 |
+
{ value: '3', short: 'Wed', label: 'Wednesday' }, { value: '4', short: 'Thu', label: 'Thursday' },
|
| 11 |
+
{ value: '5', short: 'Fri', label: 'Friday' }, { value: '6', short: 'Sat', label: 'Saturday' },
|
| 12 |
+
{ value: '0', short: 'Sun', label: 'Sunday' },
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
const browserZone = () => {
|
| 16 |
+
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; }
|
| 17 |
+
};
|
| 18 |
+
const agentFolder = (value: string) => {
|
| 19 |
+
const readable = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
| 20 |
+
if (readable) return readable;
|
| 21 |
+
if (!value) return '<agent-name>';
|
| 22 |
+
let hash = 2_166_136_261;
|
| 23 |
+
for (const character of value) hash = Math.imul(hash ^ (character.codePointAt(0) || 0), 16_777_619);
|
| 24 |
+
return `agent-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
| 25 |
+
};
|
| 26 |
+
const timeParts = (time: string) => {
|
| 27 |
+
const [hour = '9', minute = '0'] = time.split(':');
|
| 28 |
+
return { hour: String(Number(hour)), minute: String(Number(minute)) };
|
| 29 |
+
};
|
| 30 |
+
const cronFor = (preset: Preset, time: string, weekday: string, custom: string) => {
|
| 31 |
+
const { hour, minute } = timeParts(time);
|
| 32 |
+
if (preset === 'hourly') return '0 * * * *';
|
| 33 |
+
if (preset === 'daily') return `${minute} ${hour} * * *`;
|
| 34 |
+
if (preset === 'weekdays') return `${minute} ${hour} * * 1-5`;
|
| 35 |
+
if (preset === 'weekly') return `${minute} ${hour} * * ${weekday}`;
|
| 36 |
+
return custom.trim().replace(/\s+/g, ' ');
|
| 37 |
+
};
|
| 38 |
+
const fieldsForCron = (cron: string): { preset: Preset; time: string; weekday: string; custom: string } => {
|
| 39 |
+
let match;
|
| 40 |
+
if (cron === '0 * * * *') return { preset: 'hourly', time: '09:00', weekday: '1', custom: cron };
|
| 41 |
+
if ((match = cron.match(/^(\d+) (\d+) \* \* 1-5$/))) {
|
| 42 |
+
return { preset: 'weekdays', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: '1', custom: cron };
|
| 43 |
+
}
|
| 44 |
+
if ((match = cron.match(/^(\d+) (\d+) \* \* ([0-6])$/))) {
|
| 45 |
+
return { preset: 'weekly', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: match[3], custom: cron };
|
| 46 |
+
}
|
| 47 |
+
if ((match = cron.match(/^(\d+) (\d+) \* \* \*$/))) {
|
| 48 |
+
return { preset: 'daily', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: '1', custom: cron };
|
| 49 |
+
}
|
| 50 |
+
return { preset: 'custom', time: '09:00', weekday: '1', custom: cron };
|
| 51 |
+
};
|
| 52 |
+
const intervalName = (job: api.CronJob) => {
|
| 53 |
+
const c = job.schedule.cron;
|
| 54 |
+
let match;
|
| 55 |
+
if (c === '0 * * * *') return 'hourly';
|
| 56 |
+
if ((match = c.match(/^(\d+) (\d+) \* \* \*$/))) return `every day ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
|
| 57 |
+
if ((match = c.match(/^(\d+) (\d+) \* \* 1-5$/))) return `weekdays ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
|
| 58 |
+
if ((match = c.match(/^(\d+) (\d+) \* \* ([0-6])$/))) {
|
| 59 |
+
const day = DAYS.find((candidate) => candidate.value === match[3])?.label || match[3];
|
| 60 |
+
return `${day}s ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
|
| 61 |
+
}
|
| 62 |
+
return c;
|
| 63 |
+
};
|
| 64 |
+
const duration = (ms: number) => {
|
| 65 |
+
if (ms < 1000) return `${ms}ms`;
|
| 66 |
+
const seconds = Math.round(ms / 1000);
|
| 67 |
+
if (seconds < 60) return `${seconds}s`;
|
| 68 |
+
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
|
| 69 |
+
};
|
| 70 |
+
const when = (iso: string | null, zone: string, now: number) => {
|
| 71 |
+
if (!iso) return '—';
|
| 72 |
+
const value = Date.parse(iso);
|
| 73 |
+
const delta = value - now;
|
| 74 |
+
if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
|
| 75 |
+
if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
|
| 76 |
+
return compactWhen(iso, zone);
|
| 77 |
+
};
|
| 78 |
+
const compactWhen = (iso: string, zone: string) => {
|
| 79 |
+
const value = Date.parse(iso);
|
| 80 |
+
try {
|
| 81 |
+
const parts = new Intl.DateTimeFormat('en-GB', {
|
| 82 |
+
timeZone: zone, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
| 83 |
+
}).formatToParts(new Date(value));
|
| 84 |
+
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((candidate) => candidate.type === type)?.value || '';
|
| 85 |
+
return `${part('day')} ${part('month')} ${part('hour')}:${part('minute')}`;
|
| 86 |
+
} catch { return new Date(value).toISOString().slice(5, 16).replace('T', ' '); }
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
export default function CronSettings({ clis }: { clis: Cli[] }) {
|
| 90 |
+
const agents = useMemo(() => clis.filter((cli) => !EXCLUDED_CLIS.has(cli.id)), [clis]);
|
| 91 |
+
const [jobs, setJobs] = useState<api.CronJob[]>([]);
|
| 92 |
+
const [sessions, setSessions] = useState<Session[]>([]);
|
| 93 |
+
const [loading, setLoading] = useState(true);
|
| 94 |
+
const [busy, setBusy] = useState<string | null>(null);
|
| 95 |
+
const [message, setMessage] = useState('');
|
| 96 |
+
const [now, setNow] = useState(Date.now());
|
| 97 |
+
const [editingId, setEditingId] = useState<string | null>(null);
|
| 98 |
+
const formRef = useRef<HTMLFormElement>(null);
|
| 99 |
+
const [jobName, setJobName] = useState('');
|
| 100 |
+
const [agentName, setAgentName] = useState('');
|
| 101 |
+
const [cli, setCli] = useState('');
|
| 102 |
+
const [prompt, setPrompt] = useState('');
|
| 103 |
+
const [preset, setPreset] = useState<Preset>('daily');
|
| 104 |
+
const [time, setTime] = useState('09:00');
|
| 105 |
+
const [weekday, setWeekday] = useState('1');
|
| 106 |
+
const [custom, setCustom] = useState('0 9 * * *');
|
| 107 |
+
const [tz, setTz] = useState(browserZone);
|
| 108 |
+
const [runOnRestart, setRunOnRestart] = useState(true);
|
| 109 |
+
|
| 110 |
+
const load = async () => {
|
| 111 |
+
try {
|
| 112 |
+
const [cronData, tree] = await Promise.all([api.getCrons(), api.getTree()]);
|
| 113 |
+
setJobs(cronData.crons);
|
| 114 |
+
setSessions(tree.sessions);
|
| 115 |
+
} catch (error) {
|
| 116 |
+
setMessage(error instanceof Error ? error.message : 'Could not load cron jobs.');
|
| 117 |
+
} finally { setLoading(false); }
|
| 118 |
+
};
|
| 119 |
+
useEffect(() => {
|
| 120 |
+
load();
|
| 121 |
+
const poll = window.setInterval(load, 15_000);
|
| 122 |
+
const clock = window.setInterval(() => setNow(Date.now()), 30_000);
|
| 123 |
+
return () => { window.clearInterval(poll); window.clearInterval(clock); };
|
| 124 |
+
}, []);
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
if (!cli || !agents.some((agent) => agent.id === cli)) {
|
| 127 |
+
setCli(agents.find((agent) => agent.available)?.id || '');
|
| 128 |
+
}
|
| 129 |
+
}, [agents, cli]);
|
| 130 |
+
|
| 131 |
+
const scheduleCron = cronFor(preset, time, weekday, custom);
|
| 132 |
+
const existing = sessions.find((session) => session.name === agentName.trim());
|
| 133 |
+
const selected = agents.find((agent) => agent.id === cli);
|
| 134 |
+
const reset = () => {
|
| 135 |
+
setJobName(''); setAgentName(''); setPrompt(''); setPreset('daily'); setTime('09:00');
|
| 136 |
+
setWeekday('1'); setCustom('0 9 * * *'); setTz(browserZone()); setRunOnRestart(true);
|
| 137 |
+
setCli(agents.find((agent) => agent.available)?.id || ''); setEditingId(null); setMessage('');
|
| 138 |
+
};
|
| 139 |
+
const edit = (job: api.CronJob) => {
|
| 140 |
+
const schedule = fieldsForCron(job.schedule.cron);
|
| 141 |
+
setEditingId(job.id); setJobName(job.name); setAgentName(job.agent.name); setCli(job.agent.cli);
|
| 142 |
+
setPrompt(job.prompt); setPreset(schedule.preset); setTime(schedule.time); setWeekday(schedule.weekday);
|
| 143 |
+
setCustom(schedule.custom); setTz(job.schedule.tz); setRunOnRestart(job.runOnRestart); setMessage('');
|
| 144 |
+
window.requestAnimationFrame(() => {
|
| 145 |
+
formRef.current?.scrollIntoView({ block: 'start' });
|
| 146 |
+
formRef.current?.querySelector<HTMLInputElement>('#cron-job-name')?.focus({ preventScroll: true });
|
| 147 |
+
});
|
| 148 |
+
};
|
| 149 |
+
const editFromKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, job: api.CronJob) => {
|
| 150 |
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
| 151 |
+
event.preventDefault();
|
| 152 |
+
edit(job);
|
| 153 |
+
};
|
| 154 |
+
const save = async (event: FormEvent) => {
|
| 155 |
+
event.preventDefault();
|
| 156 |
+
if (!jobName.trim() || !agentName.trim() || !cli || !prompt.trim() || !scheduleCron) return;
|
| 157 |
+
const draft: api.CronDraft = {
|
| 158 |
+
name: jobName.trim(), agent: { name: agentName.trim(), cli }, prompt: prompt.trim(),
|
| 159 |
+
schedule: { cron: scheduleCron, tz: tz.trim() }, runOnRestart,
|
| 160 |
+
};
|
| 161 |
+
setBusy('save'); setMessage('');
|
| 162 |
+
try {
|
| 163 |
+
if (editingId) await api.updateCron(editingId, draft);
|
| 164 |
+
else await api.createCron(draft);
|
| 165 |
+
reset();
|
| 166 |
+
await load();
|
| 167 |
+
} catch (error) {
|
| 168 |
+
setMessage(error instanceof Error ? error.message : `Could not ${editingId ? 'update' : 'create'} the job.`);
|
| 169 |
+
} finally { setBusy(null); }
|
| 170 |
+
};
|
| 171 |
+
const act = async (id: string, action: () => Promise<unknown>) => {
|
| 172 |
+
setBusy(id); setMessage('');
|
| 173 |
+
try {
|
| 174 |
+
await action();
|
| 175 |
+
await load();
|
| 176 |
+
window.setTimeout(load, 750); // delivery may finish just after a 202 Run-now response
|
| 177 |
+
} catch (error) {
|
| 178 |
+
setMessage(error instanceof Error ? error.message : 'The cron action failed.');
|
| 179 |
+
} finally { setBusy(null); }
|
| 180 |
+
};
|
| 181 |
+
const zones = useMemo(() => {
|
| 182 |
+
try {
|
| 183 |
+
const withValues = Intl as typeof Intl & { supportedValuesOf?: (key: 'timeZone') => string[] };
|
| 184 |
+
const values = withValues.supportedValuesOf?.('timeZone') || [];
|
| 185 |
+
return values.includes('UTC') ? values : ['UTC', ...values];
|
| 186 |
+
} catch { return ['UTC']; }
|
| 187 |
+
}, []);
|
| 188 |
+
|
| 189 |
+
return (
|
| 190 |
+
<>
|
| 191 |
+
<form className="cron-form" onSubmit={save} ref={formRef}>
|
| 192 |
+
<label htmlFor="cron-job-name">Job name</label>
|
| 193 |
+
<div>
|
| 194 |
+
<input id="cron-job-name" value={jobName} onChange={(event) => setJobName(event.target.value)} placeholder="nightly deploy check" required />
|
| 195 |
+
<p>Names the job in this list, the operations log, and failures. It is separate from the agent.</p>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<label htmlFor="cron-agent-name">Agent name</label>
|
| 199 |
+
<div>
|
| 200 |
+
<input id="cron-agent-name" value={agentName} onChange={(event) => setAgentName(event.target.value)} placeholder="nightly-index" required />
|
| 201 |
+
<p>{existing
|
| 202 |
+
? `Existing ${existing.name} session will be reused; its current ${existing.cli} type is kept.`
|
| 203 |
+
: `Created on first fire in workspaces/${agentFolder(agentName)}. An existing exact name is reused.`}</p>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<span className="cron-label" id="cron-cli-label">Agent type</span>
|
| 207 |
+
<div>
|
| 208 |
+
<div className="cron-clis" role="group" aria-labelledby="cron-cli-label">
|
| 209 |
+
{agents.map((agent) => (
|
| 210 |
+
<button
|
| 211 |
+
type="button" key={agent.id} className={cli === agent.id ? 'on' : ''}
|
| 212 |
+
disabled={!agent.available} aria-pressed={cli === agent.id}
|
| 213 |
+
title={agent.available ? agent.label : `${agent.label} is not installed on this Space`}
|
| 214 |
+
onClick={() => setCli(agent.id)}
|
| 215 |
+
>
|
| 216 |
+
<Logo cli={agent.id} size={13} /> {agent.label}
|
| 217 |
+
</button>
|
| 218 |
+
))}
|
| 219 |
+
</div>
|
| 220 |
+
<p>{selected?.available
|
| 221 |
+
? 'Used only if the named agent must be created.'
|
| 222 |
+
: editingId && selected ? 'This saved type is unavailable here; updating preserves it.' : 'Unavailable agent types cannot be selected.'}</p>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
<label htmlFor="cron-prompt">Prompt</label>
|
| 226 |
+
<div>
|
| 227 |
+
<textarea id="cron-prompt" rows={4} value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Check last night’s deploy log…" required />
|
| 228 |
+
<p>Sent as a normal prompt. The agent keeps its conversation history between runs.</p>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<span className="cron-label" id="cron-schedule-label">Schedule</span>
|
| 232 |
+
<div>
|
| 233 |
+
<div className="seg cron-presets" role="group" aria-labelledby="cron-schedule-label">
|
| 234 |
+
{([
|
| 235 |
+
['hourly', 'Hourly'], ['daily', 'Every day'], ['weekdays', 'Weekdays'], ['weekly', 'Weekly'], ['custom', 'Custom cron'],
|
| 236 |
+
] as [Preset, string][]).map(([value, label]) => (
|
| 237 |
+
<button type="button" key={value} className={preset === value ? 'on' : ''} aria-pressed={preset === value} onClick={() => setPreset(value)}>{label}</button>
|
| 238 |
+
))}
|
| 239 |
+
</div>
|
| 240 |
+
<div className="cron-schedule-fields">
|
| 241 |
+
{preset !== 'hourly' && preset !== 'custom' && (
|
| 242 |
+
<label>at <input type="time" value={time} onChange={(event) => setTime(event.target.value)} required /></label>
|
| 243 |
+
)}
|
| 244 |
+
{preset === 'weekly' && (
|
| 245 |
+
<label>on <select value={weekday} onChange={(event) => setWeekday(event.target.value)}>{DAYS.map((day) => <option value={day.value} key={day.value}>{day.label}</option>)}</select></label>
|
| 246 |
+
)}
|
| 247 |
+
{preset === 'custom' && (
|
| 248 |
+
<label className="cron-expression">cron <input className="mono" value={custom} onChange={(event) => setCustom(event.target.value)} placeholder="0 9 * * *" required /></label>
|
| 249 |
+
)}
|
| 250 |
+
<label className="cron-zone">timezone <input list="cron-timezones" value={tz} onChange={(event) => setTz(event.target.value)} required /></label>
|
| 251 |
+
<datalist id="cron-timezones">{zones.map((zone) => <option value={zone} key={zone} />)}</datalist>
|
| 252 |
+
</div>
|
| 253 |
+
<p><span className="mono">{scheduleCron || '—'}</span> · stored with <span className="mono">{tz || 'timezone required'}</span>; missed fires while the Space is down are not replayed.</p>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<span className="cron-label" id="cron-restart-label">Run on restart</span>
|
| 257 |
+
<div>
|
| 258 |
+
<div className="seg" role="group" aria-labelledby="cron-restart-label">
|
| 259 |
+
<button type="button" className={runOnRestart ? 'on' : ''} aria-pressed={runOnRestart} onClick={() => setRunOnRestart(true)}>Yes</button>
|
| 260 |
+
<button type="button" className={!runOnRestart ? 'on' : ''} aria-pressed={!runOnRestart} onClick={() => setRunOnRestart(false)}>No</button>
|
| 261 |
+
</div>
|
| 262 |
+
<p>Runs once when the app comes back. This is a fresh fire, not catch-up.</p>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<div className="cron-form-actions">
|
| 266 |
+
<button className="btn-primary" type="submit" disabled={busy === 'save' || !selected || (!editingId && !selected.available)}>
|
| 267 |
+
{busy === 'save' ? (editingId ? 'Updating…' : 'Creating…') : (editingId ? 'Update job' : 'Create job')}
|
| 268 |
+
</button>
|
| 269 |
+
<button className="btn-ghost" type="button" onClick={reset}>Cancel</button>
|
| 270 |
+
</div>
|
| 271 |
+
</form>
|
| 272 |
+
|
| 273 |
+
{message && <div className="s-warn cron-message" role="alert">{message}</div>}
|
| 274 |
+
|
| 275 |
+
<h3>Scheduled jobs</h3>
|
| 276 |
+
{loading ? <div className="s-muted">Loading…</div> : jobs.length === 0 ? (
|
| 277 |
+
<div className="s-muted">No cron jobs yet.</div>
|
| 278 |
+
) : (
|
| 279 |
+
<div className="table-scroll cron-table-wrap">
|
| 280 |
+
<table className="cron-table">
|
| 281 |
+
<colgroup>
|
| 282 |
+
<col className="cron-col-job" /><col className="cron-col-agent" /><col className="cron-col-type" />
|
| 283 |
+
<col className="cron-col-interval" /><col className="cron-col-state" /><col className="cron-col-next" />
|
| 284 |
+
<col className="cron-col-last" /><col className="cron-col-actions" /><col className="cron-col-fill" />
|
| 285 |
+
</colgroup>
|
| 286 |
+
<thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th><th className="cron-fill" aria-hidden="true" /></tr></thead>
|
| 287 |
+
<tbody>{jobs.map((job) => {
|
| 288 |
+
const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
|
| 289 |
+
const lastTitle = job.last
|
| 290 |
+
? [duration(job.last.durationMs), job.last.error].filter(Boolean).join(' · ')
|
| 291 |
+
: '';
|
| 292 |
+
return (
|
| 293 |
+
<tr
|
| 294 |
+
key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
|
| 295 |
+
aria-selected={editingId === job.id}
|
| 296 |
+
onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
|
| 297 |
+
>
|
| 298 |
+
<td className="cron-name" title={job.name}>{job.name}</td>
|
| 299 |
+
<td className="cron-name" title={job.agent.name}>{job.agent.name}</td>
|
| 300 |
+
<td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
|
| 301 |
+
<td>{intervalName(job)}</td>
|
| 302 |
+
<td className={`cron-state ${job.state}`}>{job.state}</td>
|
| 303 |
+
<td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
|
| 304 |
+
<td title={lastTitle}>{job.last ? <><span className={`cron-last ${job.last.status}`}>{job.last.status}</span> <span className="cron-dim">· {compactWhen(job.last.at, job.schedule.tz)}</span></> : <span className="cron-dim">—</span>}</td>
|
| 305 |
+
<td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
|
| 306 |
+
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
|
| 307 |
+
<button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
|
| 308 |
+
<button className="btn-ghost danger" disabled={busy === job.id} onClick={() => act(job.id, async () => {
|
| 309 |
+
await api.deleteCron(job.id);
|
| 310 |
+
if (editingId === job.id) reset();
|
| 311 |
+
})}>Delete</button>
|
| 312 |
+
</span></td>
|
| 313 |
+
<td className="cron-fill" aria-hidden="true" />
|
| 314 |
+
</tr>
|
| 315 |
+
);
|
| 316 |
+
})}</tbody>
|
| 317 |
+
</table>
|
| 318 |
+
</div>
|
| 319 |
+
)}
|
| 320 |
+
</>
|
| 321 |
+
);
|
| 322 |
+
}
|
web/src/components/SettingsView.tsx
CHANGED
|
@@ -3,14 +3,16 @@ import { isPassive, isRemote, type Cli } from '../types';
|
|
| 3 |
import * as api from '../api';
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
| 5 |
import UsagePanel from './UsagePanel';
|
|
|
|
| 6 |
import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
|
| 7 |
import Logo from './Logo';
|
| 8 |
|
| 9 |
-
type Page = 'general' | 'usage' | 'skills';
|
| 10 |
const PAGES: { id: Page; label: string }[] = [
|
| 11 |
{ id: 'general', label: 'General' },
|
| 12 |
{ id: 'usage', label: 'Usage' },
|
| 13 |
{ id: 'skills', label: 'Skills' },
|
|
|
|
| 14 |
];
|
| 15 |
|
| 16 |
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
|
|
@@ -782,6 +784,14 @@ export default function SettingsView({
|
|
| 782 |
<SkillsEditor />
|
| 783 |
</div>
|
| 784 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
</div>
|
| 786 |
</div>
|
| 787 |
);
|
|
|
|
| 3 |
import * as api from '../api';
|
| 4 |
import SkillsEditor from './SkillsEditor';
|
| 5 |
import UsagePanel from './UsagePanel';
|
| 6 |
+
import CronSettings from './CronSettings';
|
| 7 |
import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
|
| 8 |
import Logo from './Logo';
|
| 9 |
|
| 10 |
+
type Page = 'general' | 'usage' | 'skills' | 'cron';
|
| 11 |
const PAGES: { id: Page; label: string }[] = [
|
| 12 |
{ id: 'general', label: 'General' },
|
| 13 |
{ id: 'usage', label: 'Usage' },
|
| 14 |
{ id: 'skills', label: 'Skills' },
|
| 15 |
+
{ id: 'cron', label: 'Cron' },
|
| 16 |
];
|
| 17 |
|
| 18 |
interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
|
|
|
|
| 784 |
<SkillsEditor />
|
| 785 |
</div>
|
| 786 |
)}
|
| 787 |
+
|
| 788 |
+
{page === 'cron' && (
|
| 789 |
+
<div className="settings-page wide cron-page">
|
| 790 |
+
<h2>Cron</h2>
|
| 791 |
+
<p className="s-help cron-intro">Send a prompt to an agent on a schedule. Jobs persist across Space restarts; if the named agent does not exist when a job fires, it is created first.</p>
|
| 792 |
+
<CronSettings clis={clis} />
|
| 793 |
+
</div>
|
| 794 |
+
)}
|
| 795 |
</div>
|
| 796 |
</div>
|
| 797 |
);
|
web/src/styles.css
CHANGED
|
@@ -1400,6 +1400,68 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1400 |
|
| 1401 |
/* settings: skills editor */
|
| 1402 |
.settings-page.wide { max-width: 980px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1403 |
/* natural height — the settings page scrolls, not the preview */
|
| 1404 |
.skills { display: flex; gap: 14px; margin-top: 12px; align-items: flex-start; }
|
| 1405 |
.skills-list { width: 220px; flex: none; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; border-right: 1px solid var(--border); padding-right: 10px; }
|
|
|
|
| 1400 |
|
| 1401 |
/* settings: skills editor */
|
| 1402 |
.settings-page.wide { max-width: 980px; }
|
| 1403 |
+
|
| 1404 |
+
/* settings: cron jobs — a labelled form followed by the operational table from
|
| 1405 |
+
the approved mock. State is plain coloured text; controls are controls, never
|
| 1406 |
+
status pills, so "running" cannot be mistaken for something clickable. */
|
| 1407 |
+
.cron-intro { max-width: 680px; margin: -8px 0 18px; }
|
| 1408 |
+
.cron-form { display: grid; grid-template-columns: 128px minmax(0, 1fr); column-gap: 20px; row-gap: 16px; padding: 16px 2px 20px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
| 1409 |
+
.cron-form > label, .cron-label { padding-top: 8px; font-size: 13px; font-weight: 600; }
|
| 1410 |
+
.cron-form > div { min-width: 0; }
|
| 1411 |
+
.cron-form input, .cron-form textarea, .cron-form select { padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; }
|
| 1412 |
+
.cron-form input:focus, .cron-form textarea:focus, .cron-form select:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); border-color: var(--accent); }
|
| 1413 |
+
.cron-form > div > input, .cron-form textarea { display: block; width: 100%; }
|
| 1414 |
+
.cron-form textarea { resize: vertical; min-height: 82px; line-height: 1.45; }
|
| 1415 |
+
.cron-form p { margin: 5px 0 0; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
|
| 1416 |
+
.cron-clis { display: flex; flex-wrap: wrap; gap: 5px; }
|
| 1417 |
+
.cron-clis button { min-height: 30px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); color: var(--muted); font: 11.5px var(--font-mono); cursor: pointer; }
|
| 1418 |
+
.cron-clis button:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text); }
|
| 1419 |
+
.cron-clis button.on { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
|
| 1420 |
+
.cron-clis button:disabled { opacity: 0.34; cursor: not-allowed; }
|
| 1421 |
+
.cron-presets { display: flex; flex-wrap: wrap; border: 0; border-radius: 0; overflow: visible; gap: 4px; }
|
| 1422 |
+
.cron-presets button { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 5px 9px; }
|
| 1423 |
+
.cron-schedule-fields { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-top: 9px; }
|
| 1424 |
+
.cron-schedule-fields label { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11.5px; }
|
| 1425 |
+
.cron-schedule-fields input[type='time'] { width: 132px; font-family: var(--font-mono); }
|
| 1426 |
+
.cron-schedule-fields select { min-width: 112px; }
|
| 1427 |
+
.cron-schedule-fields .cron-expression { flex: 1 1 210px; }
|
| 1428 |
+
.cron-expression input { flex: 1; min-width: 140px; }
|
| 1429 |
+
.cron-schedule-fields .cron-zone { flex: 1 1 230px; }
|
| 1430 |
+
.cron-zone input { flex: 1; min-width: 160px; }
|
| 1431 |
+
.cron-form-actions { grid-column: 2; display: flex; gap: 8px; }
|
| 1432 |
+
.cron-message { margin-top: 14px; }
|
| 1433 |
+
.cron-page h3 { margin-top: 24px; }
|
| 1434 |
+
.cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
|
| 1435 |
+
.cron-table { width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11.5px; }
|
| 1436 |
+
/* Every data column takes its intrinsic one-line width. The final empty column
|
| 1437 |
+
owns all surplus, keeping the useful columns grouped instead of stretching
|
| 1438 |
+
Job and Agent across the table. */
|
| 1439 |
+
.cron-col-job, .cron-col-agent, .cron-col-type, .cron-col-interval,
|
| 1440 |
+
.cron-col-state, .cron-col-next, .cron-col-last, .cron-col-actions { width: 1%; }
|
| 1441 |
+
.cron-col-fill { width: auto; }
|
| 1442 |
+
.cron-table th { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--muted); font: 500 10px var(--font-mono); letter-spacing: 0.045em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
| 1443 |
+
.cron-table td { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); vertical-align: middle; white-space: nowrap; }
|
| 1444 |
+
.cron-table .cron-fill { padding: 0; }
|
| 1445 |
+
.cron-table tbody tr { cursor: pointer; }
|
| 1446 |
+
.cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
|
| 1447 |
+
.cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
| 1448 |
+
.cron-table tbody tr.editing { box-shadow: inset 2px 0 var(--accent); }
|
| 1449 |
+
.cron-type .cli-logo { display: flex; }
|
| 1450 |
+
.cron-dim { color: var(--muted); }
|
| 1451 |
+
.cron-state.running, .cron-last.ok { color: var(--go); }
|
| 1452 |
+
.cron-state.stopped { color: var(--muted); }
|
| 1453 |
+
.cron-last.failed { color: var(--danger); }
|
| 1454 |
+
.cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
|
| 1455 |
+
.cron-actions .btn-ghost { padding: 4px 5px; border-radius: var(--r-sm); background: var(--panel); font: 500 10.5px var(--font-mono); }
|
| 1456 |
+
.cron-actions .btn-ghost:disabled { cursor: wait; }
|
| 1457 |
+
|
| 1458 |
+
@container (max-width: 560px) {
|
| 1459 |
+
.cron-form { grid-template-columns: minmax(0, 1fr); row-gap: 5px; padding-left: 0; padding-right: 0; }
|
| 1460 |
+
.cron-form > label, .cron-label { padding-top: 10px; }
|
| 1461 |
+
.cron-form-actions { grid-column: 1; margin-top: 10px; }
|
| 1462 |
+
.cron-schedule-fields { align-items: flex-start; flex-direction: column; }
|
| 1463 |
+
.cron-schedule-fields .cron-expression, .cron-schedule-fields .cron-zone { flex: none; width: 100%; }
|
| 1464 |
+
}
|
| 1465 |
/* natural height — the settings page scrolls, not the preview */
|
| 1466 |
.skills { display: flex; gap: 14px; margin-top: 12px; align-items: flex-start; }
|
| 1467 |
.skills-list { width: 220px; flex: none; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; border-right: 1px solid var(--border); padding-right: 10px; }
|
web/test/cronSettings.test.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// The Cron Settings contract in a real browser: labelled controls, correct
|
| 2 |
+
// request construction, distinct actions, plain-text state, and phone overflow.
|
| 3 |
+
import assert from 'node:assert/strict';
|
| 4 |
+
import fs from 'node:fs';
|
| 5 |
+
import os from 'node:os';
|
| 6 |
+
import path from 'node:path';
|
| 7 |
+
import { fileURLToPath } from 'node:url';
|
| 8 |
+
import { build } from 'esbuild';
|
| 9 |
+
import { chromium } from 'playwright';
|
| 10 |
+
import { chromiumLaunchOptions } from '../../scripts/test-chromium.mjs';
|
| 11 |
+
|
| 12 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 13 |
+
const WEB = path.join(HERE, '..');
|
| 14 |
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cron-settings-'));
|
| 15 |
+
const bundle = path.join(tmp, 'app.js');
|
| 16 |
+
const stub = path.join(tmp, 'api-stub.ts');
|
| 17 |
+
fs.writeFileSync(stub, `
|
| 18 |
+
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 19 |
+
let jobs = [{
|
| 20 |
+
id: 'cron_one', name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check.',
|
| 21 |
+
schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
|
| 22 |
+
state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
|
| 23 |
+
next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
|
| 24 |
+
}];
|
| 25 |
+
window.__cronCalls = [];
|
| 26 |
+
export const getCrons = () => Promise.resolve({ crons: structuredClone(jobs) });
|
| 27 |
+
export const getTree = () => Promise.resolve({ order: [], groups: [], hidden: [], sessions: [] });
|
| 28 |
+
export const createCron = (draft) => { window.__cronCalls.push(['create', structuredClone(draft)]); jobs.push({ ...draft, id: 'cron_new', state: 'running', next: '2026-08-21T07:00:00Z', createdAt: '', updatedAt: '' }); return Promise.resolve(jobs.at(-1)); };
|
| 29 |
+
export const updateCron = (id, patch) => { window.__cronCalls.push(['update', id, structuredClone(patch)]); jobs = jobs.map((job) => job.id === id ? { ...job, ...patch, next: patch.state === 'stopped' ? null : job.next } : job); return Promise.resolve(jobs.find((job) => job.id === id)); };
|
| 30 |
+
export const runCron = (id) => { window.__cronCalls.push(['run', id]); return Promise.resolve({ ok: true, agentCreated: false }); };
|
| 31 |
+
export const deleteCron = (id) => { window.__cronCalls.push(['delete', id]); jobs = jobs.filter((job) => job.id !== id); return Promise.resolve({ ok: true }); };
|
| 32 |
+
export const getConfig = () => Promise.resolve(null);
|
| 33 |
+
export const getSecrets = () => Promise.resolve({ detected: [], notes: {} });
|
| 34 |
+
export const backupStatus = () => Promise.resolve(null);
|
| 35 |
+
export const checkUpdate = () => Promise.resolve({ ok: true });
|
| 36 |
+
`);
|
| 37 |
+
|
| 38 |
+
await build({
|
| 39 |
+
stdin: {
|
| 40 |
+
resolveDir: WEB, loader: 'tsx', contents: `
|
| 41 |
+
import React from 'react';
|
| 42 |
+
import { createRoot } from 'react-dom/client';
|
| 43 |
+
import SettingsView from './src/components/SettingsView.tsx';
|
| 44 |
+
const clis = [
|
| 45 |
+
{ id: 'shell', label: 'Shell', color: '#888', available: true },
|
| 46 |
+
{ id: 'files', label: 'Files', color: '#888', available: true },
|
| 47 |
+
{ id: 'trace', label: 'Trace', color: '#888', available: true },
|
| 48 |
+
{ id: 'remote', label: 'Remote agent', color: '#888', available: true },
|
| 49 |
+
{ id: 'claude', label: 'Claude Code', color: '#d97757', available: true },
|
| 50 |
+
{ id: 'codex', label: 'Codex', color: '#5eb6a6', available: false },
|
| 51 |
+
];
|
| 52 |
+
createRoot(document.getElementById('root')).render(
|
| 53 |
+
<SettingsView page="cron" onPage={() => {}} onClose={() => {}} theme="light"
|
| 54 |
+
onToggleTheme={() => {}} clis={clis} info={{ dataDir: '/data' }} />,
|
| 55 |
+
);
|
| 56 |
+
`,
|
| 57 |
+
},
|
| 58 |
+
outfile: bundle, bundle: true, format: 'iife', platform: 'browser', logLevel: 'error',
|
| 59 |
+
plugins: [{ name: 'stub-api', setup(b) { b.onResolve({ filter: /(^|\/)\.\.?\/api$/ }, () => ({ path: stub })); } }],
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const css = fs.readFileSync(path.join(WEB, 'src/styles.css'), 'utf8');
|
| 63 |
+
const browser = await chromium.launch(chromiumLaunchOptions());
|
| 64 |
+
try {
|
| 65 |
+
const page = await browser.newPage({ viewport: { width: 390, height: 900 } });
|
| 66 |
+
await page.setContent(`<style>${css}</style><div id="root"></div>`);
|
| 67 |
+
await page.addScriptTag({ path: bundle });
|
| 68 |
+
await page.waitForSelector('.cron-table tbody tr');
|
| 69 |
+
|
| 70 |
+
assert.equal(await page.getByRole('button', { name: 'Codex' }).isDisabled(), true,
|
| 71 |
+
'unavailable CLI remains visible but cannot be selected');
|
| 72 |
+
assert.equal(await page.locator('.cron-clis button').count(), 2,
|
| 73 |
+
'the picker is the CLI catalog minus shell/files/trace/remote');
|
| 74 |
+
for (const label of ['Job name', 'Agent name', 'Prompt']) {
|
| 75 |
+
assert.ok(await page.getByLabel(label, { exact: true }).count(), `${label} has an accessible label`);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const stateStyle = await page.locator('.cron-state').evaluate((element) => {
|
| 79 |
+
const style = getComputedStyle(element);
|
| 80 |
+
return { background: style.backgroundColor, radius: style.borderRadius, color: style.color };
|
| 81 |
+
});
|
| 82 |
+
assert.equal(stateStyle.background, 'rgba(0, 0, 0, 0)', 'state is text, not a badge fill');
|
| 83 |
+
assert.equal(stateStyle.radius, '0px', 'state is text, not a rounded pill');
|
| 84 |
+
|
| 85 |
+
const overflow = await page.evaluate(() => {
|
| 86 |
+
const main = document.querySelector('.settings-main');
|
| 87 |
+
const table = document.querySelector('.cron-table-wrap');
|
| 88 |
+
return {
|
| 89 |
+
page: main.scrollWidth - main.clientWidth,
|
| 90 |
+
table: table.scrollWidth - table.clientWidth,
|
| 91 |
+
stacked: document.querySelector('#cron-job-name').getBoundingClientRect().top
|
| 92 |
+
>= document.querySelector('label[for="cron-job-name"]').getBoundingClientRect().bottom,
|
| 93 |
+
};
|
| 94 |
+
});
|
| 95 |
+
assert.equal(overflow.page, 0, 'wide job table does not make the phone page scroll sideways');
|
| 96 |
+
assert.ok(overflow.table > 0, 'the table owns its horizontal overflow');
|
| 97 |
+
assert.equal(overflow.stacked, true, 'phone form control sits below its label');
|
| 98 |
+
|
| 99 |
+
const firstRow = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 100 |
+
const compactRow = await firstRow.evaluate((row) => {
|
| 101 |
+
const cells = [...row.querySelectorAll('td')];
|
| 102 |
+
const buttons = [...row.querySelectorAll('button')];
|
| 103 |
+
return {
|
| 104 |
+
height: row.getBoundingClientRect().height,
|
| 105 |
+
noWrap: cells.every((cell) => getComputedStyle(cell).whiteSpace === 'nowrap'),
|
| 106 |
+
typeText: cells[2].textContent.trim(),
|
| 107 |
+
interval: cells[3].textContent.trim(),
|
| 108 |
+
last: cells[6].textContent.trim(),
|
| 109 |
+
buttonsFit: buttons.every((button) => button.scrollWidth <= button.clientWidth),
|
| 110 |
+
buttonBorders: buttons.map((button) => getComputedStyle(button).borderTopStyle),
|
| 111 |
+
};
|
| 112 |
+
});
|
| 113 |
+
assert.ok(compactRow.height < 40, `job row stays on one line at phone width (${compactRow.height}px)`);
|
| 114 |
+
assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
|
| 115 |
+
assert.equal(compactRow.typeText, '', 'type column is icon-only');
|
| 116 |
+
assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
|
| 117 |
+
assert.match(compactRow.last, /^ok · 19 Aug 09:00$/, 'last run keeps the complete dense date');
|
| 118 |
+
assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
|
| 119 |
+
assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
|
| 120 |
+
|
| 121 |
+
await page.setViewportSize({ width: 1200, height: 900 });
|
| 122 |
+
const contentFit = await firstRow.evaluate((row) => {
|
| 123 |
+
const cells = [...row.querySelectorAll('td')];
|
| 124 |
+
return cells.slice(0, 2).map((cell) => {
|
| 125 |
+
const range = document.createRange();
|
| 126 |
+
range.selectNodeContents(cell);
|
| 127 |
+
const textWidth = range.getBoundingClientRect().width;
|
| 128 |
+
const style = getComputedStyle(cell);
|
| 129 |
+
return cell.getBoundingClientRect().width - textWidth
|
| 130 |
+
- parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
assert.equal(contentFit.every((slack) => Math.abs(slack) < 1), true,
|
| 134 |
+
`wide Job and Agent columns hug their content (${contentFit.join(', ')}px surplus)`);
|
| 135 |
+
|
| 136 |
+
const widths = [];
|
| 137 |
+
for (const width of [1200, 980, 760, 390]) {
|
| 138 |
+
await page.setViewportSize({ width, height: 900 });
|
| 139 |
+
widths.push(await firstRow.evaluate((row) => {
|
| 140 |
+
const wrap = row.closest('.cron-table-wrap');
|
| 141 |
+
const cells = [...row.querySelectorAll('td')];
|
| 142 |
+
const buttons = [...row.querySelectorAll('button')];
|
| 143 |
+
return {
|
| 144 |
+
width: window.innerWidth,
|
| 145 |
+
overflow: wrap.scrollWidth - wrap.clientWidth,
|
| 146 |
+
clipped: cells.map((cell) => cell.scrollWidth > cell.clientWidth),
|
| 147 |
+
ellipsis: cells.slice(0, 8).some((cell) => getComputedStyle(cell).textOverflow === 'ellipsis'),
|
| 148 |
+
filler: cells[8].getBoundingClientRect().width,
|
| 149 |
+
actionFits: buttons.every((button) => button.scrollWidth <= button.clientWidth)
|
| 150 |
+
&& cells[7].scrollWidth <= cells[7].clientWidth,
|
| 151 |
+
};
|
| 152 |
+
}));
|
| 153 |
+
}
|
| 154 |
+
assert.equal(widths[0].overflow, 0, 'the wide table needs no scrollbar');
|
| 155 |
+
assert.equal(widths[0].filler > 0, true, 'wide surplus lands in the empty column after Actions');
|
| 156 |
+
for (const layout of widths) {
|
| 157 |
+
assert.deepEqual(layout.clipped.slice(0, 8), [false, false, false, false, false, false, false, false],
|
| 158 |
+
`${layout.width}px truncates no data column`);
|
| 159 |
+
assert.equal(layout.ellipsis, false, `${layout.width}px applies no ellipsis treatment`);
|
| 160 |
+
assert.equal(layout.actionFits, true, `${layout.width}px keeps every action usable`);
|
| 161 |
+
}
|
| 162 |
+
assert.equal(widths.at(-1).overflow > 0, true, 'the genuinely narrow table scrolls rather than truncating');
|
| 163 |
+
const synchronizedScroll = await firstRow.evaluate((row) => {
|
| 164 |
+
const wrap = row.closest('.cron-table-wrap');
|
| 165 |
+
const header = row.closest('table').querySelector('th');
|
| 166 |
+
const cell = row.querySelector('td');
|
| 167 |
+
const before = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
|
| 168 |
+
wrap.scrollLeft = wrap.scrollWidth;
|
| 169 |
+
const after = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
|
| 170 |
+
return {
|
| 171 |
+
moved: before.header - after.header,
|
| 172 |
+
aligned: Math.abs((before.header - before.cell) - (after.header - after.cell)),
|
| 173 |
+
scrollLeft: wrap.scrollLeft,
|
| 174 |
+
};
|
| 175 |
+
});
|
| 176 |
+
assert.equal(synchronizedScroll.scrollLeft > 0, true, 'phone table can scroll to the Actions column');
|
| 177 |
+
assert.equal(synchronizedScroll.moved > 0, true, 'the header moves with horizontal scrolling');
|
| 178 |
+
assert.equal(synchronizedScroll.aligned < 0.5, true, 'header and body columns stay aligned while scrolling');
|
| 179 |
+
|
| 180 |
+
await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
|
| 181 |
+
await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
|
| 182 |
+
await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
|
| 183 |
+
await page.getByRole('button', { name: 'Weekdays' }).click();
|
| 184 |
+
await page.locator('input[type="time"]').fill('14:25');
|
| 185 |
+
await page.getByLabel('timezone').fill('America/New_York');
|
| 186 |
+
await page.getByRole('button', { name: 'No', exact: true }).click();
|
| 187 |
+
await page.getByRole('button', { name: 'Create job' }).click();
|
| 188 |
+
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'create'));
|
| 189 |
+
const created = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'create')[1]);
|
| 190 |
+
assert.deepEqual(created, {
|
| 191 |
+
name: 'weekday digest', agent: { name: 'digest-agent', cli: 'claude' }, prompt: 'Summarize yesterday.',
|
| 192 |
+
schedule: { cron: '25 14 * * 1-5', tz: 'America/New_York' }, runOnRestart: false,
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 196 |
+
await row.click();
|
| 197 |
+
await page.getByRole('button', { name: 'Update job' }).waitFor();
|
| 198 |
+
assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check');
|
| 199 |
+
assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage');
|
| 200 |
+
assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
|
| 201 |
+
assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
|
| 202 |
+
assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
|
| 203 |
+
assert.equal(await page.getByRole('button', { name: 'Every day' }).getAttribute('aria-pressed'), 'true');
|
| 204 |
+
assert.equal(await page.getByRole('button', { name: 'Yes', exact: true }).getAttribute('aria-pressed'), 'true');
|
| 205 |
+
assert.equal(await page.getByRole('button', { name: 'Codex' }).getAttribute('aria-pressed'), 'true');
|
| 206 |
+
assert.equal(await page.getByRole('button', { name: 'Update job' }).isDisabled(), false,
|
| 207 |
+
'an unavailable saved CLI is preserved without blocking edits to the other fields');
|
| 208 |
+
|
| 209 |
+
await page.getByLabel('Prompt', { exact: true }).fill('Check and summarize.');
|
| 210 |
+
await page.getByRole('button', { name: 'Weekly' }).click();
|
| 211 |
+
await page.locator('input[type="time"]').fill('10:15');
|
| 212 |
+
await page.locator('.cron-schedule-fields select').selectOption('4');
|
| 213 |
+
await page.getByLabel('timezone').fill('Asia/Tokyo');
|
| 214 |
+
await page.getByRole('button', { name: 'No', exact: true }).click();
|
| 215 |
+
await page.getByRole('button', { name: 'Update job' }).click();
|
| 216 |
+
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
|
| 217 |
+
const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
|
| 218 |
+
assert.deepEqual(edited, ['update', 'cron_one', {
|
| 219 |
+
name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check and summarize.',
|
| 220 |
+
schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
|
| 221 |
+
}], 'row selection round-trips every persisted field through PUT');
|
| 222 |
+
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
|
| 223 |
+
|
| 224 |
+
await row.getByRole('button', { name: 'Run now' }).click();
|
| 225 |
+
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'an action click does not also select the row');
|
| 226 |
+
await row.getByRole('button', { name: 'Stop' }).click();
|
| 227 |
+
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].state));
|
| 228 |
+
const actions = await page.evaluate(() => window.__cronCalls.map((call) => call[0]));
|
| 229 |
+
assert.ok(actions.includes('run') && actions.includes('update'), 'Run now and Stop are separate wired actions');
|
| 230 |
+
await row.getByRole('button', { name: 'Delete' }).click();
|
| 231 |
+
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'delete'));
|
| 232 |
+
assert.ok((await page.evaluate(() => window.__cronCalls)).some((call) => call[0] === 'delete' && call[1] === 'cron_one'));
|
| 233 |
+
|
| 234 |
+
await page.close();
|
| 235 |
+
} finally {
|
| 236 |
+
await browser.close();
|
| 237 |
+
fs.rmSync(tmp, { recursive: true, force: true });
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
console.log('cron settings: form, catalog, actions, state treatment, and phone overflow agree');
|