opencode-jobs 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -22
- package/dist/cli.js +34 -0
- package/dist/index.js +183 -13
- package/dist/internals.d.ts +2 -1
- package/dist/job.d.ts +7 -0
- package/dist/paths.d.ts +3 -0
- package/dist/runs.d.ts +2 -0
- package/package.json +4 -2
- package/skill/opencode-jobs/SKILL.md +7 -0
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ exit. Job definitions, run history, session state, and logs are kept.
|
|
|
68
68
|
|
|
69
69
|
Add `--purge` to also delete the project's job definitions
|
|
70
70
|
(`.opencode/scheduler/`) and its scheduler data (run scripts, run history,
|
|
71
|
-
session state, and logs):
|
|
71
|
+
session state, run locks, worktrees, and logs):
|
|
72
72
|
|
|
73
73
|
```sh
|
|
74
74
|
opencode-jobs uninstall --purge
|
|
@@ -87,17 +87,17 @@ can also be used from setup scripts and CI.
|
|
|
87
87
|
|
|
88
88
|
## Tools
|
|
89
89
|
|
|
90
|
-
| Tool | What it does
|
|
91
|
-
| ----------------- |
|
|
92
|
-
| `schedule_job` | Create or update a job (cron schedule, prompt or custom command, session mode, guard, timeout) |
|
|
93
|
-
| `list_jobs` | List job definitions in the project with next/last run status
|
|
94
|
-
| `get_job` | Show one job: definition, timer state, last runs, recent log tail
|
|
95
|
-
| `run_job` | Fire a job now, through the exact script the timer would run
|
|
96
|
-
| `job_logs` | Tail a job's log (scheduled and manual runs both append)
|
|
97
|
-
| `delete_job` | Delete a job, its units, run script, and session state
|
|
98
|
-
| `enable_project` | Install systemd user units for all jobs in the project and register it
|
|
99
|
-
| `disable_project` | Stop and remove the project's units (jobs and history are kept)
|
|
100
|
-
| `list_projects` | List all projects that have enabled jobs
|
|
90
|
+
| Tool | What it does |
|
|
91
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `schedule_job` | Create or update a job (cron schedule, prompt or custom command, session mode, guard, worktree, timeout) |
|
|
93
|
+
| `list_jobs` | List job definitions in the project with next/last run status |
|
|
94
|
+
| `get_job` | Show one job: definition, timer state, last runs, recent log tail |
|
|
95
|
+
| `run_job` | Fire a job now, through the exact script the timer would run |
|
|
96
|
+
| `job_logs` | Tail a job's log (scheduled and manual runs both append) |
|
|
97
|
+
| `delete_job` | Delete a job, its units, run script, and session state |
|
|
98
|
+
| `enable_project` | Install systemd user units for all jobs in the project and register it |
|
|
99
|
+
| `disable_project` | Stop and remove the project's units (jobs and history are kept) |
|
|
100
|
+
| `list_projects` | List all projects that have enabled jobs |
|
|
101
101
|
|
|
102
102
|
## Job definitions
|
|
103
103
|
|
|
@@ -114,6 +114,7 @@ them in PRs like any other code:
|
|
|
114
114
|
},
|
|
115
115
|
"session": "compact+last",
|
|
116
116
|
"guard": "! git diff --quiet",
|
|
117
|
+
"worktree": true,
|
|
117
118
|
"timeoutSeconds": 1800
|
|
118
119
|
}
|
|
119
120
|
```
|
|
@@ -127,6 +128,8 @@ them in PRs like any other code:
|
|
|
127
128
|
- `guard` — shell command run first; a non-zero exit skips the run (recorded
|
|
128
129
|
as `skipped`). Example: `"! git diff --quiet"` runs only when the repo has
|
|
129
130
|
changes.
|
|
131
|
+
- `worktree` — run in a fresh git worktree instead of the project checkout
|
|
132
|
+
(below).
|
|
130
133
|
- `timeoutSeconds` — hard limit; systemd stops the run with SIGTERM.
|
|
131
134
|
|
|
132
135
|
### Session modes
|
|
@@ -141,6 +144,48 @@ them in PRs like any other code:
|
|
|
141
144
|
Tracked modes (`persist`/`compact`/`compact+last`) self-heal a deleted or
|
|
142
145
|
stale session by retrying once with a fresh session.
|
|
143
146
|
|
|
147
|
+
### Worktree jobs
|
|
148
|
+
|
|
149
|
+
Set `"worktree": true` (or an object) to run a job in a fresh git worktree
|
|
150
|
+
instead of the project checkout, so scheduled runs never race your editor or
|
|
151
|
+
leave the main tree dirty:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
"worktree": {
|
|
155
|
+
"base": "/srv/worktrees",
|
|
156
|
+
"ref": "origin/main",
|
|
157
|
+
"commitMessage": "nightly sweep: automated fixes"
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Each run:
|
|
162
|
+
|
|
163
|
+
1. takes a per-job lock (so an overlapping timer and `run_job` invocation
|
|
164
|
+
cannot fight over the worktree — the later run is recorded as
|
|
165
|
+
`skipped`), then creates a worktree at `<base>/<slug>` on a new branch
|
|
166
|
+
`opencode-jobs/<slug>/<timestamp>-<pid>`, starting from `ref`
|
|
167
|
+
(default: `HEAD` of the project checkout). A leftover worktree from a
|
|
168
|
+
crashed or timed-out run is committed first (recovery commit on its own
|
|
169
|
+
branch) and then removed — unless the recovery commit fails (for
|
|
170
|
+
example a `pre-commit` hook rejects the changes), in which case the
|
|
171
|
+
stale worktree is kept and the run fails instead of discarding work;
|
|
172
|
+
2. runs the job with the worktree as the working directory — if the
|
|
173
|
+
project is a subdirectory of a larger repository, the job runs in the
|
|
174
|
+
matching subdirectory of the worktree;
|
|
175
|
+
3. commits everything (`git add -A`) as `opencode-jobs` with
|
|
176
|
+
`--no-gpg-sign`, then removes the worktree — the branch and its commits
|
|
177
|
+
stay in the repository, and the run record stores `worktreeBranch` and
|
|
178
|
+
`worktreeCommit`.
|
|
179
|
+
|
|
180
|
+
If the safety commit fails, the worktree is kept on disk rather than
|
|
181
|
+
discarded. The default base is
|
|
182
|
+
`~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>` (respecting
|
|
183
|
+
`XDG_STATE_HOME`); override with `base` (relative paths resolve against the
|
|
184
|
+
project directory, and the base should be dedicated to scheduler worktrees).
|
|
185
|
+
Worktree jobs require `git` and a git repository — a missing repo fails the
|
|
186
|
+
run with a clear record. Branches accumulate per run by design; merge or
|
|
187
|
+
delete them when you no longer need the work.
|
|
188
|
+
|
|
144
189
|
## How it works
|
|
145
190
|
|
|
146
191
|
- `enable_project` generates, for each job: a frozen POSIX `run-<slug>.sh`
|
|
@@ -149,6 +194,9 @@ stale session by retrying once with a fresh session.
|
|
|
149
194
|
- Run scripts work standalone: they append a JSONL record (status, exit
|
|
150
195
|
code, duration, session id) per run, capture `--format json` output for
|
|
151
196
|
tracked session modes, and never leave temp files behind.
|
|
197
|
+
- Worktree jobs create a fresh worktree per run, commit all changes to a
|
|
198
|
+
per-run branch, and remove the worktree afterwards — see
|
|
199
|
+
[Worktree jobs](#worktree-jobs).
|
|
152
200
|
- A global registry tracks enabled projects so `list_projects` and
|
|
153
201
|
`disable_project` work from any session.
|
|
154
202
|
- Schedules are cron expressions compiled to systemd `OnCalendar` and
|
|
@@ -156,15 +204,17 @@ stale session by retrying once with a fresh session.
|
|
|
156
204
|
|
|
157
205
|
## Storage
|
|
158
206
|
|
|
159
|
-
| What | Where
|
|
160
|
-
| ------------------- |
|
|
161
|
-
| Job definitions | `<project>/.opencode/scheduler/jobs/<slug>.json` (git-committed)
|
|
162
|
-
| Run scripts | `~/.config/opencode/scheduler/scopes/<scopeId>/run-<slug>.sh`
|
|
163
|
-
| Run history (JSONL) | `~/.config/opencode/scheduler/runs/<scopeId>/<slug>.jsonl`
|
|
164
|
-
| Session state | `~/.config/opencode/scheduler/sessions/<scopeId>/<slug>.txt`
|
|
165
|
-
|
|
|
166
|
-
|
|
|
167
|
-
|
|
|
207
|
+
| What | Where |
|
|
208
|
+
| ------------------- | --------------------------------------------------------------------------------------- |
|
|
209
|
+
| Job definitions | `<project>/.opencode/scheduler/jobs/<slug>.json` (git-committed) |
|
|
210
|
+
| Run scripts | `~/.config/opencode/scheduler/scopes/<scopeId>/run-<slug>.sh` |
|
|
211
|
+
| Run history (JSONL) | `~/.config/opencode/scheduler/runs/<scopeId>/<slug>.jsonl` |
|
|
212
|
+
| Session state | `~/.config/opencode/scheduler/sessions/<scopeId>/<slug>.txt` |
|
|
213
|
+
| Run locks | `~/.config/opencode/scheduler/locks/<scopeId>/<slug>.lock` (worktree jobs) |
|
|
214
|
+
| Job worktrees | `~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>` (removed after each run) |
|
|
215
|
+
| Job logs | `~/.config/opencode/logs/scheduler/<scopeId>/<slug>.log` |
|
|
216
|
+
| Project registry | `~/.config/opencode/scheduler/registry.json` |
|
|
217
|
+
| systemd units | `~/.config/systemd/user/opencode-sched-<scope>-<slug>.{service,timer}` |
|
|
168
218
|
|
|
169
219
|
`scopeId` is a stable hash of the project path, so multiple projects can
|
|
170
220
|
define jobs without colliding. Because it is path-derived, moving or renaming
|
|
@@ -182,6 +232,7 @@ up `~/.config/opencode/scheduler/` (and the unit files) manually afterwards.
|
|
|
182
232
|
|
|
183
233
|
- Linux with a systemd user session (jobs run via `systemctl --user`)
|
|
184
234
|
- `curl` (used by `compact`/`compact+last` modes only)
|
|
235
|
+
- `git` and `flock` (from util-linux; used by `worktree` jobs only)
|
|
185
236
|
- POSIX `sh` (generated scripts are `sh`/`dash`-verified)
|
|
186
237
|
- The `opencode` CLI available to the timer environment
|
|
187
238
|
|
|
@@ -193,7 +244,8 @@ Not supported on macOS or Windows.
|
|
|
193
244
|
npm install
|
|
194
245
|
npm run check # prettier + eslint + tsc
|
|
195
246
|
npm run build # Bun plugin bundle + Node CLI bundle + declarations
|
|
196
|
-
npm
|
|
247
|
+
npm test # build + Node integration tests
|
|
248
|
+
npm run smoke # full behavioral gate, including generated scripts and CLI install
|
|
197
249
|
```
|
|
198
250
|
|
|
199
251
|
Node >= 20 is required for the CLI and dev toolchain; the plugin runs on Bun
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
// src/install.ts
|
|
8
8
|
import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
|
|
9
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
9
10
|
import path5 from "node:path";
|
|
10
11
|
import { z as z3 } from "zod";
|
|
11
12
|
|
|
@@ -17,6 +18,15 @@ import { homedir } from "node:os";
|
|
|
17
18
|
function configRoot() {
|
|
18
19
|
return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
19
20
|
}
|
|
21
|
+
function stateRoot() {
|
|
22
|
+
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
23
|
+
}
|
|
24
|
+
function worktreesDirectory(scopeId) {
|
|
25
|
+
return path.join(stateRoot(), "opencode", "scheduler", "worktrees", scopeId);
|
|
26
|
+
}
|
|
27
|
+
function locksDirectory(scopeId) {
|
|
28
|
+
return path.join(schedulerDirectory(), "locks", scopeId);
|
|
29
|
+
}
|
|
20
30
|
function schedulerDirectory() {
|
|
21
31
|
return path.join(configRoot(), "opencode", "scheduler");
|
|
22
32
|
}
|
|
@@ -210,6 +220,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
|
|
|
210
220
|
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
211
221
|
});
|
|
212
222
|
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
223
|
+
var worktreeSchema = z.union([
|
|
224
|
+
z.literal(true),
|
|
225
|
+
z.strictObject({
|
|
226
|
+
base: nonEmptyStringSchema.optional(),
|
|
227
|
+
ref: nonEmptyStringSchema.optional(),
|
|
228
|
+
commitMessage: nonEmptyStringSchema.optional()
|
|
229
|
+
})
|
|
230
|
+
]).transform((value) => {
|
|
231
|
+
if (value === true)
|
|
232
|
+
return {};
|
|
233
|
+
return {
|
|
234
|
+
...value.base !== undefined && { base: value.base },
|
|
235
|
+
...value.ref !== undefined && { ref: value.ref },
|
|
236
|
+
...value.commitMessage !== undefined && {
|
|
237
|
+
commitMessage: value.commitMessage
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
});
|
|
213
241
|
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
214
242
|
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
215
243
|
try {
|
|
@@ -225,6 +253,7 @@ var jobFileSchema = z.strictObject({
|
|
|
225
253
|
run: runSpecSchema,
|
|
226
254
|
session: sessionSchema.default("new"),
|
|
227
255
|
guard: guardSchema.optional(),
|
|
256
|
+
worktree: worktreeSchema.optional(),
|
|
228
257
|
timeoutSeconds: timeoutSchema.optional(),
|
|
229
258
|
createdAt: z.string().optional(),
|
|
230
259
|
updatedAt: z.string().optional()
|
|
@@ -457,6 +486,8 @@ function purgeProjectData(projectDirectory) {
|
|
|
457
486
|
runsDirectory(scopeId),
|
|
458
487
|
sessionStateDirectory(scopeId),
|
|
459
488
|
logDirectory(scopeId),
|
|
489
|
+
locksDirectory(scopeId),
|
|
490
|
+
worktreesDirectory(scopeId),
|
|
460
491
|
path5.join(abs, ".opencode", "scheduler")
|
|
461
492
|
];
|
|
462
493
|
const paths = [];
|
|
@@ -466,6 +497,9 @@ function purgeProjectData(projectDirectory) {
|
|
|
466
497
|
rmSync2(target, { recursive: true, force: true });
|
|
467
498
|
paths.push(target);
|
|
468
499
|
}
|
|
500
|
+
if (paths.includes(worktreesDirectory(scopeId))) {
|
|
501
|
+
spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
|
|
502
|
+
}
|
|
469
503
|
removeDirectoryIfEmpty(path5.join(abs, ".opencode"));
|
|
470
504
|
return { paths };
|
|
471
505
|
}
|
package/dist/index.js
CHANGED
|
@@ -145,6 +145,12 @@ import { homedir } from "os";
|
|
|
145
145
|
function configRoot() {
|
|
146
146
|
return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
147
147
|
}
|
|
148
|
+
function stateRoot() {
|
|
149
|
+
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
150
|
+
}
|
|
151
|
+
function worktreesDirectory(scopeId) {
|
|
152
|
+
return path.join(stateRoot(), "opencode", "scheduler", "worktrees", scopeId);
|
|
153
|
+
}
|
|
148
154
|
function schedulerDirectory() {
|
|
149
155
|
return path.join(configRoot(), "opencode", "scheduler");
|
|
150
156
|
}
|
|
@@ -281,6 +287,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
|
|
|
281
287
|
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
282
288
|
});
|
|
283
289
|
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
290
|
+
var worktreeSchema = z.union([
|
|
291
|
+
z.literal(true),
|
|
292
|
+
z.strictObject({
|
|
293
|
+
base: nonEmptyStringSchema.optional(),
|
|
294
|
+
ref: nonEmptyStringSchema.optional(),
|
|
295
|
+
commitMessage: nonEmptyStringSchema.optional()
|
|
296
|
+
})
|
|
297
|
+
]).transform((value) => {
|
|
298
|
+
if (value === true)
|
|
299
|
+
return {};
|
|
300
|
+
return {
|
|
301
|
+
...value.base !== undefined && { base: value.base },
|
|
302
|
+
...value.ref !== undefined && { ref: value.ref },
|
|
303
|
+
...value.commitMessage !== undefined && {
|
|
304
|
+
commitMessage: value.commitMessage
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
});
|
|
284
308
|
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
285
309
|
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
286
310
|
try {
|
|
@@ -296,6 +320,7 @@ var jobFileSchema = z.strictObject({
|
|
|
296
320
|
run: runSpecSchema,
|
|
297
321
|
session: sessionSchema.default("new"),
|
|
298
322
|
guard: guardSchema.optional(),
|
|
323
|
+
worktree: worktreeSchema.optional(),
|
|
299
324
|
timeoutSeconds: timeoutSchema.optional(),
|
|
300
325
|
createdAt: z.string().optional(),
|
|
301
326
|
updatedAt: z.string().optional()
|
|
@@ -332,6 +357,11 @@ function validateGuard(value, context) {
|
|
|
332
357
|
return;
|
|
333
358
|
return parseWithContext(guardSchema, value, context);
|
|
334
359
|
}
|
|
360
|
+
function validateWorktree(value, context) {
|
|
361
|
+
if (value === undefined)
|
|
362
|
+
return;
|
|
363
|
+
return parseWithContext(worktreeSchema, value, context);
|
|
364
|
+
}
|
|
335
365
|
function loadJobFile(file, expectedSlug) {
|
|
336
366
|
const stem = path2.basename(file, ".json");
|
|
337
367
|
try {
|
|
@@ -362,6 +392,9 @@ function loadJobFile(file, expectedSlug) {
|
|
|
362
392
|
run: definition.run,
|
|
363
393
|
...definition.session !== "new" && { session: definition.session },
|
|
364
394
|
...definition.guard !== undefined && { guard: definition.guard },
|
|
395
|
+
...definition.worktree !== undefined && {
|
|
396
|
+
worktree: definition.worktree
|
|
397
|
+
},
|
|
365
398
|
...definition.timeoutSeconds !== undefined && {
|
|
366
399
|
timeoutSeconds: definition.timeoutSeconds
|
|
367
400
|
},
|
|
@@ -454,7 +487,9 @@ var runRecordSchema = z3.object({
|
|
|
454
487
|
status: optionalString,
|
|
455
488
|
exitCode: optionalNumber,
|
|
456
489
|
sessionId: optionalString,
|
|
457
|
-
startedBy: optionalString
|
|
490
|
+
startedBy: optionalString,
|
|
491
|
+
worktreeBranch: optionalString,
|
|
492
|
+
worktreeCommit: optionalString
|
|
458
493
|
});
|
|
459
494
|
function readRunRecords(scopeId, slug, limit) {
|
|
460
495
|
const file = runsFile(scopeId, slug);
|
|
@@ -492,7 +527,8 @@ function formatRunLine(record) {
|
|
|
492
527
|
const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
|
|
493
528
|
const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
|
|
494
529
|
const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
|
|
495
|
-
|
|
530
|
+
const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
|
|
531
|
+
return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
|
|
496
532
|
}
|
|
497
533
|
function tailFile(file, lines, maxChars) {
|
|
498
534
|
if (!existsSync2(file))
|
|
@@ -544,6 +580,101 @@ function guardScriptLines(guard) {
|
|
|
544
580
|
"fi"
|
|
545
581
|
];
|
|
546
582
|
}
|
|
583
|
+
function worktreeDefaultRoot(scopeId) {
|
|
584
|
+
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/scheduler/worktrees/" + scopeId;
|
|
585
|
+
}
|
|
586
|
+
function worktreePrologueLines(job, scopeId) {
|
|
587
|
+
const base = job.worktree?.base;
|
|
588
|
+
return [
|
|
589
|
+
"wt_enabled=1",
|
|
590
|
+
'orig_pwd="$(pwd)"',
|
|
591
|
+
'lock_dir="$config_root/opencode/scheduler/locks/$scope"',
|
|
592
|
+
'mkdir -p "$lock_dir"',
|
|
593
|
+
'exec 9>"$lock_dir/$slug.lock"',
|
|
594
|
+
"if ! flock -n 9; then",
|
|
595
|
+
' echo "scheduler: another run of $slug is already active, skipping"',
|
|
596
|
+
" finish skipped 0",
|
|
597
|
+
" exit 0",
|
|
598
|
+
"fi",
|
|
599
|
+
base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
|
|
600
|
+
'if ! mkdir -p "$wt_root"; then',
|
|
601
|
+
' echo "scheduler: cannot create worktree base $wt_root"',
|
|
602
|
+
" finish failed 1",
|
|
603
|
+
" exit 1",
|
|
604
|
+
"fi",
|
|
605
|
+
'wt_root="$(cd "$wt_root" && pwd)"',
|
|
606
|
+
'wt_path="$wt_root/$slug"',
|
|
607
|
+
'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
|
|
608
|
+
`wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
|
|
609
|
+
'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
|
|
610
|
+
'wt_sub="${wt_sub%/}"',
|
|
611
|
+
'if [ -d "$wt_path" ]; then',
|
|
612
|
+
' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
|
|
613
|
+
" wt_stale_saved=1",
|
|
614
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
|
|
615
|
+
' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
616
|
+
' git -C "$wt_path" -c user.name=opencode-jobs -c user.email=scheduler@opencode.invalid commit --no-gpg-sign -m "opencode-jobs: $slug recovery (stale worktree)" >/dev/null 2>&1 || wt_stale_saved=0',
|
|
617
|
+
" fi",
|
|
618
|
+
' if [ "$wt_stale_saved" -eq 1 ]; then',
|
|
619
|
+
' git worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
620
|
+
' rm -rf "$wt_path"',
|
|
621
|
+
" else",
|
|
622
|
+
' echo "scheduler: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
|
|
623
|
+
' wt_branch=""',
|
|
624
|
+
" finish failed 1",
|
|
625
|
+
" exit 1",
|
|
626
|
+
" fi",
|
|
627
|
+
" else",
|
|
628
|
+
' echo "scheduler: removing unexpected directory at $wt_path"',
|
|
629
|
+
' rm -rf "$wt_path"',
|
|
630
|
+
" fi",
|
|
631
|
+
" git worktree prune >/dev/null 2>&1",
|
|
632
|
+
"fi",
|
|
633
|
+
'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
|
|
634
|
+
' echo "scheduler: failed to create worktree $wt_path (worktree jobs require a git repository)"',
|
|
635
|
+
' wt_branch=""',
|
|
636
|
+
" finish failed 1",
|
|
637
|
+
" exit 1",
|
|
638
|
+
"fi",
|
|
639
|
+
'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
|
|
640
|
+
' echo "scheduler: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
|
|
641
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
642
|
+
' rm -rf "$wt_path"',
|
|
643
|
+
' wt_branch=""',
|
|
644
|
+
" finish failed 1",
|
|
645
|
+
" exit 1",
|
|
646
|
+
"fi",
|
|
647
|
+
'if [ -n "$wt_sub" ]; then',
|
|
648
|
+
' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
|
|
649
|
+
"else",
|
|
650
|
+
' cd "$wt_path" || { finish failed 1; exit 1; }',
|
|
651
|
+
"fi"
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
function worktreeEpilogueLines(options) {
|
|
655
|
+
const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
|
|
656
|
+
return [
|
|
657
|
+
'if [ "$wt_enabled" -eq 1 ]; then',
|
|
658
|
+
` wt_msg=${message}`,
|
|
659
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1',
|
|
660
|
+
" wt_keep=0",
|
|
661
|
+
' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
662
|
+
' if git -C "$wt_path" -c user.name=opencode-jobs -c user.email=scheduler@opencode.invalid commit --no-gpg-sign -m "$wt_msg" >/dev/null 2>&1; then',
|
|
663
|
+
' echo "scheduler: committed worktree changes to branch $wt_branch"',
|
|
664
|
+
" else",
|
|
665
|
+
' echo "scheduler: worktree commit failed, keeping worktree at $wt_path"',
|
|
666
|
+
" wt_keep=1",
|
|
667
|
+
" fi",
|
|
668
|
+
" fi",
|
|
669
|
+
' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
|
|
670
|
+
' if [ "$wt_keep" -eq 0 ]; then',
|
|
671
|
+
' cd "$wt_root" 2>/dev/null',
|
|
672
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
|
|
673
|
+
' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
|
|
674
|
+
" fi",
|
|
675
|
+
"fi"
|
|
676
|
+
];
|
|
677
|
+
}
|
|
547
678
|
var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
|
|
548
679
|
var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
549
680
|
var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
|
|
@@ -664,17 +795,20 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
664
795
|
'run_id="$(date +%s%N)-$$"',
|
|
665
796
|
"started=$(date +%s)",
|
|
666
797
|
'new_session=""',
|
|
798
|
+
'wt_branch=""',
|
|
799
|
+
'wt_commit=""',
|
|
667
800
|
`export OPENCODE_PERMISSION='{"question":"deny"}'`,
|
|
668
801
|
'export OPENCODE_SCHEDULER_RUN_ID="$run_id"',
|
|
669
802
|
"finish() {",
|
|
670
803
|
' status="$1"',
|
|
671
804
|
' code="$2"',
|
|
672
805
|
" ended=$(date +%s)",
|
|
673
|
-
String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" >> "$record_file"`,
|
|
806
|
+
String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s","worktreeBranch":"%s","worktreeCommit":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" "$wt_branch" "$wt_commit" >> "$record_file"`,
|
|
674
807
|
"}",
|
|
675
808
|
"trap 'finish timeout 124; exit 124' TERM INT",
|
|
676
809
|
...job.guard === undefined ? [] : guardScriptLines(job.guard),
|
|
677
810
|
String.raw`printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"startedBy":"%s","status":"running"}\n' "$run_id" "$slug" "$scope" "$started" "$started_by" >> "$record_file"`,
|
|
811
|
+
...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
|
|
678
812
|
`oc_agent=${shQuote(job.run.agent ?? "")}`,
|
|
679
813
|
`oc_model=${shQuote(job.run.model ?? "")}`,
|
|
680
814
|
"prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
|
|
@@ -754,6 +888,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
754
888
|
"fi"
|
|
755
889
|
] : ['run_opencode "" 0', "code=$?"],
|
|
756
890
|
"trap - TERM INT",
|
|
891
|
+
...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
|
|
757
892
|
'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
|
|
758
893
|
...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
|
|
759
894
|
"finish success 0",
|
|
@@ -1020,16 +1155,40 @@ function scheduleJobOutput(input, directory) {
|
|
|
1020
1155
|
} catch (error) {
|
|
1021
1156
|
return fail(errorMessage(error));
|
|
1022
1157
|
}
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1158
|
+
const hasWorktreeOptions = input.worktreeBase !== undefined || input.worktreeRef !== undefined || input.worktreeCommitMessage !== undefined;
|
|
1159
|
+
if (hasWorktreeOptions && input.worktree !== true) {
|
|
1160
|
+
return fail("set worktree: true to enable worktree options (worktreeBase, worktreeRef, worktreeCommitMessage)");
|
|
1161
|
+
}
|
|
1162
|
+
let run;
|
|
1163
|
+
let session;
|
|
1164
|
+
let guard;
|
|
1165
|
+
let worktree;
|
|
1166
|
+
let timeoutSeconds;
|
|
1167
|
+
try {
|
|
1168
|
+
run = validateRunSpec({
|
|
1169
|
+
prompt: input.prompt,
|
|
1170
|
+
command: input.command,
|
|
1171
|
+
arguments: input.arguments,
|
|
1172
|
+
agent: input.agent,
|
|
1173
|
+
model: input.model
|
|
1174
|
+
}, "job");
|
|
1175
|
+
session = validateSession(input.session, "job");
|
|
1176
|
+
guard = validateGuard(input.guard, "job");
|
|
1177
|
+
worktree = validateWorktree(input.worktree === true ? {
|
|
1178
|
+
...input.worktreeBase !== undefined && {
|
|
1179
|
+
base: input.worktreeBase
|
|
1180
|
+
},
|
|
1181
|
+
...input.worktreeRef !== undefined && {
|
|
1182
|
+
ref: input.worktreeRef
|
|
1183
|
+
},
|
|
1184
|
+
...input.worktreeCommitMessage !== undefined && {
|
|
1185
|
+
commitMessage: input.worktreeCommitMessage
|
|
1186
|
+
}
|
|
1187
|
+
} : undefined, "job");
|
|
1188
|
+
timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
return fail(errorMessage(error));
|
|
1191
|
+
}
|
|
1033
1192
|
const existing = loadJobFile(path6.join(jobsDirectory(directory), `${slug}.json`), slug);
|
|
1034
1193
|
const job = {
|
|
1035
1194
|
slug,
|
|
@@ -1038,6 +1197,7 @@ function scheduleJobOutput(input, directory) {
|
|
|
1038
1197
|
run,
|
|
1039
1198
|
...session !== "new" && { session },
|
|
1040
1199
|
...guard !== undefined && { guard },
|
|
1200
|
+
...worktree !== undefined && { worktree },
|
|
1041
1201
|
...timeoutSeconds !== undefined && { timeoutSeconds },
|
|
1042
1202
|
createdAt: existing.ok ? existing.job.createdAt : nowIso(),
|
|
1043
1203
|
updatedAt: nowIso()
|
|
@@ -1050,6 +1210,8 @@ function scheduleJobOutput(input, directory) {
|
|
|
1050
1210
|
];
|
|
1051
1211
|
if (session !== "new")
|
|
1052
1212
|
lines.push(`Session: ${session}`);
|
|
1213
|
+
if (worktree !== undefined)
|
|
1214
|
+
lines.push(`Worktree: yes (base ${worktree.base ?? "default"}, branch opencode-jobs/${slug}/<run>)`);
|
|
1053
1215
|
const entry = registryEntry(directory);
|
|
1054
1216
|
if (entry === undefined) {
|
|
1055
1217
|
lines.push("Project not enabled yet. Run enable_project to install the systemd timer.");
|
|
@@ -1102,6 +1264,10 @@ function showJobOutput(slugInput, directory) {
|
|
|
1102
1264
|
];
|
|
1103
1265
|
if (job.guard !== undefined)
|
|
1104
1266
|
lines.push(`Guard: ${job.guard} (must exit 0 for the run to start)`);
|
|
1267
|
+
if (job.worktree !== undefined) {
|
|
1268
|
+
const base = job.worktree.base ?? worktreesDirectory(scopeId);
|
|
1269
|
+
lines.push(`Worktree: fresh per run at ${base} (from ${job.worktree.ref ?? "HEAD"}) \u2014 changes are committed to opencode-jobs/${job.slug}/\u2026 before the worktree is removed`);
|
|
1270
|
+
}
|
|
1105
1271
|
if (job.session !== undefined) {
|
|
1106
1272
|
const state = sessionStateFile(scopeId, job.slug);
|
|
1107
1273
|
const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
|
|
@@ -1250,6 +1416,10 @@ var scheduleJobTool = tool({
|
|
|
1250
1416
|
arguments: tool.schema.string().optional().describe("Arguments passed to the custom command"),
|
|
1251
1417
|
session: tool.schema.string().optional().describe(`Session continuity between runs: "new" (default, fresh session each run), "persist" (continue the same session), "compact" (continue the same session; after each run the history is compacted into a summary the next run starts from), "compact+last" (like compact, but the run's final result message is re-injected after the summary so the next run starts from summary plus last result)`),
|
|
1252
1418
|
guard: tool.schema.string().optional().describe('Shell command run before the job; the run only starts if it exits 0, otherwise it is recorded as skipped (applies to run_job too). E.g. "! git diff --quiet" to run only when the repo has changes'),
|
|
1419
|
+
worktree: tool.schema.boolean().optional().describe("Run the job in a fresh git worktree instead of the project checkout: the worktree is created from worktreeRef (default HEAD), the job runs inside it, all changes are committed to a per-run branch opencode-jobs/<slug>/\u2026, and the worktree is removed afterwards (kept if the safety commit fails). Requires the project to be a git repository"),
|
|
1420
|
+
worktreeBase: tool.schema.string().optional().describe("Parent directory for the worktree (default: ~/.local/state/opencode/scheduler/worktrees/<scopeId>/<slug>). Relative paths resolve against the project directory; it should be dedicated to scheduler worktrees"),
|
|
1421
|
+
worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
|
|
1422
|
+
worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
|
|
1253
1423
|
agent: tool.schema.string().optional().describe("Agent to use for the run"),
|
|
1254
1424
|
model: tool.schema.string().optional().describe("Model to use for the run"),
|
|
1255
1425
|
timeoutSeconds: tool.schema.number().optional().describe("Hard timeout in seconds (0 or omitted disables). systemd stops the run with SIGTERM after this"),
|
package/dist/internals.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { deriveScopeId, shQuote, slugify } from "./paths.js";
|
|
2
2
|
import { cronToOnCalendar, describeCron, parseCron } from "./cron.js";
|
|
3
|
-
import { validateGuard, validateRunSpec, validateSession } from "./job.js";
|
|
3
|
+
import { validateGuard, validateRunSpec, validateSession, validateWorktree } from "./job.js";
|
|
4
4
|
import { buildOpencodeArguments, runScriptContent, serviceContent, timerContent } from "./systemd.js";
|
|
5
5
|
export declare const internals: {
|
|
6
6
|
slugify: typeof slugify;
|
|
@@ -12,6 +12,7 @@ export declare const internals: {
|
|
|
12
12
|
validateRunSpec: typeof validateRunSpec;
|
|
13
13
|
validateGuard: typeof validateGuard;
|
|
14
14
|
validateSession: typeof validateSession;
|
|
15
|
+
validateWorktree: typeof validateWorktree;
|
|
15
16
|
buildOpencodeArguments: typeof buildOpencodeArguments;
|
|
16
17
|
runScriptContent: typeof runScriptContent;
|
|
17
18
|
serviceContent: typeof serviceContent;
|
package/dist/job.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface CommandRun extends RunOptions {
|
|
|
12
12
|
export type RunSpec = PromptRun | CommandRun;
|
|
13
13
|
export declare const SESSION_MODES: readonly ["new", "persist", "compact", "compact+last"];
|
|
14
14
|
export type SessionMode = (typeof SESSION_MODES)[number];
|
|
15
|
+
export interface WorktreeOptions {
|
|
16
|
+
base?: string;
|
|
17
|
+
ref?: string;
|
|
18
|
+
commitMessage?: string;
|
|
19
|
+
}
|
|
15
20
|
export interface Job {
|
|
16
21
|
slug: string;
|
|
17
22
|
name: string;
|
|
@@ -19,6 +24,7 @@ export interface Job {
|
|
|
19
24
|
run: RunSpec;
|
|
20
25
|
session?: SessionMode;
|
|
21
26
|
guard?: string;
|
|
27
|
+
worktree?: WorktreeOptions;
|
|
22
28
|
timeoutSeconds?: number;
|
|
23
29
|
createdAt: string;
|
|
24
30
|
updatedAt: string;
|
|
@@ -34,6 +40,7 @@ export declare function validateRunSpec(run: unknown, context: string): RunSpec;
|
|
|
34
40
|
export declare function validateSession(value: unknown, context: string): SessionMode;
|
|
35
41
|
export declare function validateTimeout(value: unknown, context: string): number | undefined;
|
|
36
42
|
export declare function validateGuard(value: unknown, context: string): string | undefined;
|
|
43
|
+
export declare function validateWorktree(value: unknown, context: string): WorktreeOptions | undefined;
|
|
37
44
|
export declare function loadJobFile(file: string, expectedSlug?: string): JobResult;
|
|
38
45
|
export declare function loadJobs(workdir: string): {
|
|
39
46
|
jobs: Job[];
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare function configRoot(): string;
|
|
2
|
+
export declare function stateRoot(): string;
|
|
3
|
+
export declare function worktreesDirectory(scopeId: string): string;
|
|
4
|
+
export declare function locksDirectory(scopeId: string): string;
|
|
2
5
|
export declare function schedulerDirectory(): string;
|
|
3
6
|
export declare function registryPath(): string;
|
|
4
7
|
export declare function scopeDirectory(scopeId: string): string;
|
package/dist/runs.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ declare const runRecordSchema: z.ZodObject<{
|
|
|
10
10
|
exitCode: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
11
11
|
sessionId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
12
12
|
startedBy: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
13
|
+
worktreeBranch: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
14
|
+
worktreeCommit: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
13
15
|
}, z.core.$strip>;
|
|
14
16
|
export type RunRecord = z.infer<typeof runRecordSchema>;
|
|
15
17
|
export declare function readRunRecords(scopeId: string, slug: string, limit: number): RunRecord[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "opencode plugin that schedules recurring agent jobs as systemd user timers, with git-committable job definitions, run history, and session continuity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -29,9 +29,11 @@
|
|
|
29
29
|
"prepublishOnly": "npm run clean && npm run build",
|
|
30
30
|
"format": "prettier --write .",
|
|
31
31
|
"lint": "eslint",
|
|
32
|
+
"test": "npm run build && npm run test:integration",
|
|
33
|
+
"test:integration": "node --test test/integration/*.test.mjs",
|
|
32
34
|
"typecheck": "tsc --noEmit",
|
|
33
35
|
"check": "prettier --check . && eslint && tsc --noEmit",
|
|
34
|
-
"smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs"
|
|
36
|
+
"smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs && npm run test:integration"
|
|
35
37
|
},
|
|
36
38
|
"keywords": [
|
|
37
39
|
"opencode",
|
|
@@ -34,6 +34,13 @@ scheduler state directly.
|
|
|
34
34
|
`compact` for summary continuity, or `compact+last` when the previous final
|
|
35
35
|
result must also remain visible.
|
|
36
36
|
- A `guard` is a shell command that must exit zero for the job to run.
|
|
37
|
+
- Set `worktree: true` when the job should not touch the user's checkout:
|
|
38
|
+
each run gets a fresh git worktree (default base
|
|
39
|
+
`~/.local/state/opencode/scheduler/worktrees/…`, override with
|
|
40
|
+
`worktree.base`), all changes are committed to a per-run branch
|
|
41
|
+
`opencode-jobs/<slug>/…`, and the worktree is removed. Overlapping runs
|
|
42
|
+
of the same job are skipped via a lock; subdirectory projects run in
|
|
43
|
+
the matching worktree subdirectory. Requires a git repository.
|
|
37
44
|
- Scheduled jobs require Linux, a systemd user session, and an `opencode`
|
|
38
45
|
executable available to the timer environment.
|
|
39
46
|
|