opencode-jobs 0.2.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -20
- package/dist/cli.js +1055 -75
- package/dist/index.d.ts +1 -1
- package/dist/index.js +274 -137
- package/dist/install.d.ts +3 -0
- package/dist/management.d.ts +9 -0
- package/dist/migration.d.ts +9 -0
- package/dist/paths.d.ts +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/systemd.d.ts +1 -1
- package/dist/tools.d.ts +1 -1
- package/package.json +1 -1
- package/skill/opencode-jobs/SKILL.md +4 -4
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path9 from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
// src/install.ts
|
|
8
|
-
import { existsSync as
|
|
8
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, rmSync as rmSync2, rmdirSync as rmdirSync2, statSync } from "node:fs";
|
|
9
9
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
10
|
-
import
|
|
10
|
+
import path7 from "node:path";
|
|
11
11
|
import { z as z3 } from "zod";
|
|
12
12
|
|
|
13
13
|
// src/paths.ts
|
|
@@ -22,32 +22,41 @@ function stateRoot() {
|
|
|
22
22
|
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
23
23
|
}
|
|
24
24
|
function worktreesDirectory(scopeId) {
|
|
25
|
-
return path.join(stateRoot(), "opencode", "
|
|
25
|
+
return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
|
|
26
26
|
}
|
|
27
27
|
function locksDirectory(scopeId) {
|
|
28
|
-
return path.join(
|
|
28
|
+
return path.join(jobsStateDirectory(), "locks", scopeId);
|
|
29
29
|
}
|
|
30
|
-
function
|
|
31
|
-
return path.join(configRoot(), "opencode", "
|
|
30
|
+
function jobsStateDirectory() {
|
|
31
|
+
return path.join(configRoot(), "opencode", "jobs");
|
|
32
32
|
}
|
|
33
33
|
function registryPath() {
|
|
34
|
-
return path.join(
|
|
34
|
+
return path.join(jobsStateDirectory(), "registry.json");
|
|
35
35
|
}
|
|
36
36
|
function scopeDirectory(scopeId) {
|
|
37
|
-
return path.join(
|
|
37
|
+
return path.join(jobsStateDirectory(), "scopes", scopeId);
|
|
38
38
|
}
|
|
39
39
|
function runsDirectory(scopeId) {
|
|
40
|
-
return path.join(
|
|
40
|
+
return path.join(jobsStateDirectory(), "runs", scopeId);
|
|
41
|
+
}
|
|
42
|
+
function runsFile(scopeId, slug) {
|
|
43
|
+
return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
|
|
41
44
|
}
|
|
42
45
|
function sessionStateDirectory(scopeId) {
|
|
43
|
-
return path.join(
|
|
46
|
+
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
44
47
|
}
|
|
45
48
|
function logDirectory(scopeId) {
|
|
46
|
-
return path.join(configRoot(), "opencode", "logs", "
|
|
49
|
+
return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
|
|
50
|
+
}
|
|
51
|
+
function logFile(scopeId, slug) {
|
|
52
|
+
return path.join(logDirectory(scopeId), `${slug}.log`);
|
|
47
53
|
}
|
|
48
54
|
function systemdUserDirectory() {
|
|
49
55
|
return path.join(configRoot(), "systemd", "user");
|
|
50
56
|
}
|
|
57
|
+
function jobsDirectory(workdir) {
|
|
58
|
+
return path.join(workdir, ".opencode", "jobs");
|
|
59
|
+
}
|
|
51
60
|
function unitBase(scopeId, slug) {
|
|
52
61
|
return `opencode-sched-${scopeId}-${slug}`;
|
|
53
62
|
}
|
|
@@ -64,12 +73,30 @@ function slugify(input) {
|
|
|
64
73
|
const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
|
|
65
74
|
return slug.length > 0 ? slug : "job";
|
|
66
75
|
}
|
|
76
|
+
function shQuote(value) {
|
|
77
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
78
|
+
}
|
|
79
|
+
function unitQuote(value) {
|
|
80
|
+
if (/^[A-Za-z0-9_@:=./-]*$/.test(value))
|
|
81
|
+
return value;
|
|
82
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}"`;
|
|
83
|
+
}
|
|
84
|
+
function escapeUnitText(value) {
|
|
85
|
+
return value.replaceAll("%", "%%").replaceAll(/\s+/g, " ").trim();
|
|
86
|
+
}
|
|
67
87
|
function atomicWrite(file, content) {
|
|
68
88
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
69
89
|
const temporary = `${file}.tmp`;
|
|
70
90
|
writeFileSync(temporary, content);
|
|
71
91
|
renameSync(temporary, file);
|
|
72
92
|
}
|
|
93
|
+
function atomicWriteExecutable(file, content) {
|
|
94
|
+
atomicWrite(file, content);
|
|
95
|
+
chmodSync(file, 493);
|
|
96
|
+
}
|
|
97
|
+
function nowIso() {
|
|
98
|
+
return new Date().toISOString();
|
|
99
|
+
}
|
|
73
100
|
function deriveScopeId(workdir) {
|
|
74
101
|
const abs = path.resolve(workdir);
|
|
75
102
|
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
|
|
@@ -77,12 +104,15 @@ function deriveScopeId(workdir) {
|
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
// src/project.ts
|
|
80
|
-
import
|
|
107
|
+
import path5 from "node:path";
|
|
81
108
|
|
|
82
109
|
// src/job.ts
|
|
110
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
111
|
+
import path2 from "node:path";
|
|
83
112
|
import { z } from "zod";
|
|
84
113
|
|
|
85
114
|
// src/cron.ts
|
|
115
|
+
var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
86
116
|
var DOW_NAMES = {
|
|
87
117
|
sun: 0,
|
|
88
118
|
mon: 1,
|
|
@@ -167,6 +197,42 @@ function parseCron(expression) {
|
|
|
167
197
|
dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
|
|
168
198
|
};
|
|
169
199
|
}
|
|
200
|
+
function pad2(value) {
|
|
201
|
+
return String(value).padStart(2, "0");
|
|
202
|
+
}
|
|
203
|
+
function fmtList(values) {
|
|
204
|
+
return values.map((v) => pad2(v)).join(",");
|
|
205
|
+
}
|
|
206
|
+
function cronToOnCalendar(sets) {
|
|
207
|
+
const timePart = `${fmtList(sets.hour)}:${fmtList(sets.minute)}:00`;
|
|
208
|
+
const months = sets.month.length === 12 ? "*" : fmtList(sets.month);
|
|
209
|
+
const doms = sets.dom.length === 31 ? "*" : fmtList(sets.dom);
|
|
210
|
+
const dows = sets.dow.length === 7 ? "" : `${sets.dow.map((d) => DOW_LABELS[d]).join(",")} `;
|
|
211
|
+
if (sets.dow.length === 7)
|
|
212
|
+
return [`*-${months}-${doms} ${timePart}`];
|
|
213
|
+
if (sets.dom.length === 31 && sets.month.length === 12)
|
|
214
|
+
return [`${dows}*-*-* ${timePart}`];
|
|
215
|
+
return [
|
|
216
|
+
`${dows}*-${months}-* ${timePart}`,
|
|
217
|
+
`*-${months}-${doms} ${timePart}`
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
function describeCron(sets) {
|
|
221
|
+
const timeDesc = sets.minute.length === 1 && sets.hour.length === 1 ? `at ${pad2(sets.hour[0] ?? 0)}:${pad2(sets.minute[0] ?? 0)}` : `at minute ${sets.minute.join(",")} of hour ${sets.hour.join(",")}`;
|
|
222
|
+
const isDowAll = sets.dow.length === 7;
|
|
223
|
+
const isDomAll = sets.dom.length === 31;
|
|
224
|
+
let dayDesc;
|
|
225
|
+
if (isDowAll && isDomAll)
|
|
226
|
+
dayDesc = "every day";
|
|
227
|
+
else if (isDowAll)
|
|
228
|
+
dayDesc = `on day ${sets.dom.join(",")} of the month`;
|
|
229
|
+
else if (isDomAll)
|
|
230
|
+
dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")}`;
|
|
231
|
+
else
|
|
232
|
+
dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")} or day ${sets.dom.join(",")}`;
|
|
233
|
+
const monthDesc = sets.month.length === 12 ? "" : ` in month ${sets.month.join(",")}`;
|
|
234
|
+
return `${timeDesc} ${dayDesc}${monthDesc}`;
|
|
235
|
+
}
|
|
170
236
|
|
|
171
237
|
// src/json.ts
|
|
172
238
|
function errorMessage(error) {
|
|
@@ -258,10 +324,82 @@ var jobFileSchema = z.strictObject({
|
|
|
258
324
|
createdAt: z.string().optional(),
|
|
259
325
|
updatedAt: z.string().optional()
|
|
260
326
|
});
|
|
327
|
+
function formatValidationError(error) {
|
|
328
|
+
const issue = error.issues[0];
|
|
329
|
+
if (issue === undefined)
|
|
330
|
+
return "invalid job definition";
|
|
331
|
+
const field = issue.path.join(".");
|
|
332
|
+
return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
|
|
333
|
+
}
|
|
334
|
+
function loadJobFile(file, expectedSlug) {
|
|
335
|
+
const stem = path2.basename(file, ".json");
|
|
336
|
+
try {
|
|
337
|
+
const object = JSON.parse(readFileSync(file, "utf8"));
|
|
338
|
+
const result = jobFileSchema.safeParse(object);
|
|
339
|
+
if (!result.success) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: `${stem}.json: ${formatValidationError(result.error)}`
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const definition = result.data;
|
|
346
|
+
const slug = definition.slug ?? stem;
|
|
347
|
+
if (slug !== stem)
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: `${stem}.json: "slug" ("${slug}") must match filename`
|
|
351
|
+
};
|
|
352
|
+
if (expectedSlug !== undefined && slug !== expectedSlug)
|
|
353
|
+
return { ok: false, error: `${stem}.json: unexpected slug` };
|
|
354
|
+
const timestamp = nowIso();
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
job: {
|
|
358
|
+
slug,
|
|
359
|
+
name: definition.name,
|
|
360
|
+
schedule: definition.schedule,
|
|
361
|
+
run: definition.run,
|
|
362
|
+
...definition.session !== "new" && { session: definition.session },
|
|
363
|
+
...definition.guard !== undefined && { guard: definition.guard },
|
|
364
|
+
...definition.worktree !== undefined && {
|
|
365
|
+
worktree: definition.worktree
|
|
366
|
+
},
|
|
367
|
+
...definition.timeoutSeconds !== undefined && {
|
|
368
|
+
timeoutSeconds: definition.timeoutSeconds
|
|
369
|
+
},
|
|
370
|
+
createdAt: definition.createdAt ?? timestamp,
|
|
371
|
+
updatedAt: definition.updatedAt ?? timestamp
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function loadJobs(workdir) {
|
|
379
|
+
const directory = jobsDirectory(workdir);
|
|
380
|
+
if (!existsSync(directory))
|
|
381
|
+
return { jobs: [], errors: [] };
|
|
382
|
+
const jobs = [];
|
|
383
|
+
const errors = [];
|
|
384
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
385
|
+
for (const entry of entries) {
|
|
386
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
387
|
+
continue;
|
|
388
|
+
const result = loadJobFile(path2.join(directory, entry.name));
|
|
389
|
+
if (result.ok)
|
|
390
|
+
jobs.push(result.job);
|
|
391
|
+
else
|
|
392
|
+
errors.push(result.error);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
jobs: jobs.toSorted((a, b) => a.slug.localeCompare(b.slug)),
|
|
396
|
+
errors
|
|
397
|
+
};
|
|
398
|
+
}
|
|
261
399
|
|
|
262
400
|
// src/registry.ts
|
|
263
|
-
import { readFileSync } from "node:fs";
|
|
264
|
-
import
|
|
401
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
402
|
+
import path3 from "node:path";
|
|
265
403
|
import { z as z2 } from "zod";
|
|
266
404
|
var registryEntrySchema = z2.looseObject({
|
|
267
405
|
scopeId: z2.string(),
|
|
@@ -274,19 +412,22 @@ var registryFileSchema = z2.object({
|
|
|
274
412
|
version: z2.literal(1),
|
|
275
413
|
projects: z2.record(z2.string(), z2.unknown())
|
|
276
414
|
});
|
|
415
|
+
function readRegistryFile(file) {
|
|
416
|
+
const parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
417
|
+
const result = registryFileSchema.safeParse(parsed);
|
|
418
|
+
if (!result.success)
|
|
419
|
+
throw new Error(`Invalid job registry: ${file}`);
|
|
420
|
+
const projects = {};
|
|
421
|
+
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
422
|
+
const entry = registryEntrySchema.safeParse(value);
|
|
423
|
+
if (entry.success)
|
|
424
|
+
projects[key] = entry.data;
|
|
425
|
+
}
|
|
426
|
+
return { version: 1, projects };
|
|
427
|
+
}
|
|
277
428
|
function loadRegistry() {
|
|
278
429
|
try {
|
|
279
|
-
|
|
280
|
-
const result = registryFileSchema.safeParse(parsed);
|
|
281
|
-
if (!result.success)
|
|
282
|
-
return { version: 1, projects: {} };
|
|
283
|
-
const projects = {};
|
|
284
|
-
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
285
|
-
const entry = registryEntrySchema.safeParse(value);
|
|
286
|
-
if (entry.success)
|
|
287
|
-
projects[key] = entry.data;
|
|
288
|
-
}
|
|
289
|
-
return { version: 1, projects };
|
|
430
|
+
return readRegistryFile(registryPath());
|
|
290
431
|
} catch {
|
|
291
432
|
return { version: 1, projects: {} };
|
|
292
433
|
}
|
|
@@ -296,16 +437,398 @@ function saveRegistry(registry) {
|
|
|
296
437
|
`);
|
|
297
438
|
}
|
|
298
439
|
function registryEntry(workdir) {
|
|
299
|
-
return loadRegistry().projects[
|
|
440
|
+
return loadRegistry().projects[path3.resolve(workdir)];
|
|
300
441
|
}
|
|
301
442
|
|
|
302
443
|
// src/systemd.ts
|
|
303
|
-
import { existsSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "node:fs";
|
|
444
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync } from "node:fs";
|
|
304
445
|
import { spawnSync } from "node:child_process";
|
|
305
|
-
import
|
|
446
|
+
import path4 from "node:path";
|
|
447
|
+
import { homedir as homedir2 } from "node:os";
|
|
448
|
+
function findOpencode() {
|
|
449
|
+
const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
450
|
+
if (override !== undefined && override.length > 0)
|
|
451
|
+
return override;
|
|
452
|
+
const which = spawnSync("sh", ["-c", "command -v opencode"], {
|
|
453
|
+
encoding: "utf8"
|
|
454
|
+
});
|
|
455
|
+
const onPath = which.stdout.trim();
|
|
456
|
+
if (which.status === 0 && onPath.length > 0)
|
|
457
|
+
return onPath;
|
|
458
|
+
const candidates = [
|
|
459
|
+
path4.join(homedir2(), ".opencode/bin/opencode"),
|
|
460
|
+
"/usr/local/bin/opencode",
|
|
461
|
+
"/usr/bin/opencode"
|
|
462
|
+
];
|
|
463
|
+
for (const candidate of candidates) {
|
|
464
|
+
if (existsSync2(candidate))
|
|
465
|
+
return candidate;
|
|
466
|
+
}
|
|
467
|
+
return "opencode";
|
|
468
|
+
}
|
|
469
|
+
function guardScriptLines(guard) {
|
|
470
|
+
return [
|
|
471
|
+
`guard=${shQuote(guard)}`,
|
|
472
|
+
'sh -c "$guard"',
|
|
473
|
+
"guard_code=$?",
|
|
474
|
+
'if [ "$guard_code" -ne 0 ]; then',
|
|
475
|
+
' echo "guard exited $guard_code, skipping run"',
|
|
476
|
+
' finish skipped "$guard_code"',
|
|
477
|
+
" exit 0",
|
|
478
|
+
"fi"
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
function worktreeDefaultRoot(scopeId) {
|
|
482
|
+
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
|
|
483
|
+
}
|
|
484
|
+
function worktreePrologueLines(job, scopeId) {
|
|
485
|
+
const base = job.worktree?.base;
|
|
486
|
+
return [
|
|
487
|
+
"wt_enabled=1",
|
|
488
|
+
'orig_pwd="$(pwd)"',
|
|
489
|
+
'lock_dir="$config_root/opencode/jobs/locks/$scope"',
|
|
490
|
+
'mkdir -p "$lock_dir"',
|
|
491
|
+
'exec 9>"$lock_dir/$slug.lock"',
|
|
492
|
+
"if ! flock -n 9; then",
|
|
493
|
+
' echo "opencode-jobs: another run of $slug is already active, skipping"',
|
|
494
|
+
" finish skipped 0",
|
|
495
|
+
" exit 0",
|
|
496
|
+
"fi",
|
|
497
|
+
base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
|
|
498
|
+
'if ! mkdir -p "$wt_root"; then',
|
|
499
|
+
' echo "opencode-jobs: cannot create worktree base $wt_root"',
|
|
500
|
+
" finish failed 1",
|
|
501
|
+
" exit 1",
|
|
502
|
+
"fi",
|
|
503
|
+
'wt_root="$(cd "$wt_root" && pwd)"',
|
|
504
|
+
'wt_path="$wt_root/$slug"',
|
|
505
|
+
'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
|
|
506
|
+
`wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
|
|
507
|
+
'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
|
|
508
|
+
'wt_sub="${wt_sub%/}"',
|
|
509
|
+
'if [ -d "$wt_path" ]; then',
|
|
510
|
+
' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
|
|
511
|
+
" wt_stale_saved=1",
|
|
512
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
|
|
513
|
+
' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
514
|
+
' git -C "$wt_path" -c user.name=opencode-jobs -c user.email=jobs@opencode.invalid commit --no-gpg-sign -m "opencode-jobs: $slug recovery (stale worktree)" >/dev/null 2>&1 || wt_stale_saved=0',
|
|
515
|
+
" fi",
|
|
516
|
+
' if [ "$wt_stale_saved" -eq 1 ]; then',
|
|
517
|
+
' git worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
518
|
+
' rm -rf "$wt_path"',
|
|
519
|
+
" else",
|
|
520
|
+
' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
|
|
521
|
+
' wt_branch=""',
|
|
522
|
+
" finish failed 1",
|
|
523
|
+
" exit 1",
|
|
524
|
+
" fi",
|
|
525
|
+
" else",
|
|
526
|
+
' echo "opencode-jobs: removing unexpected directory at $wt_path"',
|
|
527
|
+
' rm -rf "$wt_path"',
|
|
528
|
+
" fi",
|
|
529
|
+
" git worktree prune >/dev/null 2>&1",
|
|
530
|
+
"fi",
|
|
531
|
+
'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
|
|
532
|
+
' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
|
|
533
|
+
' wt_branch=""',
|
|
534
|
+
" finish failed 1",
|
|
535
|
+
" exit 1",
|
|
536
|
+
"fi",
|
|
537
|
+
'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
|
|
538
|
+
' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
|
|
539
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
540
|
+
' rm -rf "$wt_path"',
|
|
541
|
+
' wt_branch=""',
|
|
542
|
+
" finish failed 1",
|
|
543
|
+
" exit 1",
|
|
544
|
+
"fi",
|
|
545
|
+
'if [ -n "$wt_sub" ]; then',
|
|
546
|
+
' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
|
|
547
|
+
"else",
|
|
548
|
+
' cd "$wt_path" || { finish failed 1; exit 1; }',
|
|
549
|
+
"fi"
|
|
550
|
+
];
|
|
551
|
+
}
|
|
552
|
+
function worktreeEpilogueLines(options) {
|
|
553
|
+
const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
|
|
554
|
+
return [
|
|
555
|
+
'if [ "$wt_enabled" -eq 1 ]; then',
|
|
556
|
+
` wt_msg=${message}`,
|
|
557
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1',
|
|
558
|
+
" wt_keep=0",
|
|
559
|
+
' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
560
|
+
' if git -C "$wt_path" -c user.name=opencode-jobs -c user.email=jobs@opencode.invalid commit --no-gpg-sign -m "$wt_msg" >/dev/null 2>&1; then',
|
|
561
|
+
' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
|
|
562
|
+
" else",
|
|
563
|
+
' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
|
|
564
|
+
" wt_keep=1",
|
|
565
|
+
" fi",
|
|
566
|
+
" fi",
|
|
567
|
+
' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
|
|
568
|
+
' if [ "$wt_keep" -eq 0 ]; then',
|
|
569
|
+
' cd "$wt_root" 2>/dev/null',
|
|
570
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
|
|
571
|
+
' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
|
|
572
|
+
" fi",
|
|
573
|
+
"fi"
|
|
574
|
+
];
|
|
575
|
+
}
|
|
306
576
|
var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
|
|
307
577
|
var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
308
578
|
var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
|
|
579
|
+
function extractSessionIdLines(target, indent = "") {
|
|
580
|
+
return [
|
|
581
|
+
`${indent}${target}="$(sed -n '${SESSION_ID_SED}' "$json_out" | head -n 1)"`
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
function compactSessionLines() {
|
|
585
|
+
return [
|
|
586
|
+
"compact_session() {",
|
|
587
|
+
' csid="$1"',
|
|
588
|
+
" if ! command -v curl >/dev/null 2>&1; then",
|
|
589
|
+
' echo "opencode-jobs: curl not available, skipping compaction"',
|
|
590
|
+
" return 0",
|
|
591
|
+
" fi",
|
|
592
|
+
' serve_out="$(mktemp)"',
|
|
593
|
+
' serve_err="$(mktemp)"',
|
|
594
|
+
` OPENCODE_CONFIG_CONTENT='{"compaction":{"tail_turns":0}}' "$oc_bin" serve --port 0 >"$serve_out" 2>"$serve_err" &`,
|
|
595
|
+
" serve_pid=$!",
|
|
596
|
+
' serve_port=""',
|
|
597
|
+
" tries=0",
|
|
598
|
+
' while [ "$tries" -lt 100 ]; do',
|
|
599
|
+
String.raw` serve_port="$(sed -n 's/.*listening on http:\/\/127\.0\.0\.1:\([0-9][0-9]*\).*/\1/p' "$serve_out" | head -n 1)"`,
|
|
600
|
+
' if [ -n "$serve_port" ]; then break; fi',
|
|
601
|
+
' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
|
|
602
|
+
" sleep 0.1",
|
|
603
|
+
" tries=$((tries + 1))",
|
|
604
|
+
" done",
|
|
605
|
+
' if [ -z "$serve_port" ]; then',
|
|
606
|
+
' echo "opencode-jobs: compaction server failed to start"',
|
|
607
|
+
' sed -n "1,10p" "$serve_err" >&2',
|
|
608
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
609
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
610
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
611
|
+
" return 0",
|
|
612
|
+
" fi",
|
|
613
|
+
" healthy=0",
|
|
614
|
+
" tries=0",
|
|
615
|
+
' while [ "$tries" -lt 50 ]; do',
|
|
616
|
+
' if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$serve_port/global/health"; then',
|
|
617
|
+
" healthy=1",
|
|
618
|
+
" break",
|
|
619
|
+
" fi",
|
|
620
|
+
' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
|
|
621
|
+
" sleep 0.2",
|
|
622
|
+
" tries=$((tries + 1))",
|
|
623
|
+
" done",
|
|
624
|
+
' if [ "$healthy" -ne 1 ]; then',
|
|
625
|
+
' echo "opencode-jobs: compaction server never became healthy"',
|
|
626
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
627
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
628
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
629
|
+
" return 0",
|
|
630
|
+
" fi",
|
|
631
|
+
' cs_provider=""',
|
|
632
|
+
' cs_model=""',
|
|
633
|
+
' case "$oc_model" in',
|
|
634
|
+
" */*)",
|
|
635
|
+
' cs_provider="${oc_model%%/*}"',
|
|
636
|
+
' cs_model="${oc_model#*/}"',
|
|
637
|
+
" ;;",
|
|
638
|
+
" esac",
|
|
639
|
+
' if [ -z "$cs_provider" ]; then',
|
|
640
|
+
' defaults="$(curl -s --max-time 10 "http://127.0.0.1:$serve_port/config/providers")"',
|
|
641
|
+
String.raw` cs_provider="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_PROVIDER_SED}' | head -n 1)"`,
|
|
642
|
+
String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
|
|
643
|
+
" fi",
|
|
644
|
+
' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
|
|
645
|
+
' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
|
|
646
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
647
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
648
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
649
|
+
" return 0",
|
|
650
|
+
" fi",
|
|
651
|
+
' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
|
|
652
|
+
String.raw` result="$(curl -s --max-time 900 -X POST -H 'content-type: application/json' -d "{\"providerID\":\"$cs_provider\",\"modelID\":\"$cs_model\"}" "http://127.0.0.1:$serve_port/session/$csid/summarize")"`,
|
|
653
|
+
' if [ "$result" != "true" ]; then',
|
|
654
|
+
' echo "opencode-jobs: compaction failed: $result"',
|
|
655
|
+
" fi",
|
|
656
|
+
' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
|
|
657
|
+
String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
|
|
658
|
+
` http="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 -X POST -H 'content-type: application/json' -d "$inject_body" "http://127.0.0.1:$serve_port/session/$csid/message")"`,
|
|
659
|
+
' if [ "$http" != "200" ]; then',
|
|
660
|
+
' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
|
|
661
|
+
" fi",
|
|
662
|
+
" fi",
|
|
663
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
664
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
665
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
666
|
+
"}"
|
|
667
|
+
];
|
|
668
|
+
}
|
|
669
|
+
function runScriptContent(job, scopeId, opencodeBin) {
|
|
670
|
+
const mode = job.session ?? "new";
|
|
671
|
+
const isTracked = mode !== "new";
|
|
672
|
+
const isCompact = mode === "compact" || mode === "compact+last";
|
|
673
|
+
const isKeepLast = mode === "compact+last";
|
|
674
|
+
return [
|
|
675
|
+
"#!/bin/sh",
|
|
676
|
+
"set -u",
|
|
677
|
+
`slug=${shQuote(job.slug)}`,
|
|
678
|
+
`scope=${shQuote(scopeId)}`,
|
|
679
|
+
`oc_bin=${shQuote(opencodeBin)}`,
|
|
680
|
+
'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
|
|
681
|
+
'runs="$config_root/opencode/jobs/runs/$scope"',
|
|
682
|
+
'mkdir -p "$runs"',
|
|
683
|
+
'record_file="$runs/$slug.jsonl"',
|
|
684
|
+
...isTracked ? [
|
|
685
|
+
'sessions="$config_root/opencode/jobs/sessions/$scope"',
|
|
686
|
+
'mkdir -p "$sessions"',
|
|
687
|
+
'state_file="$sessions/$slug.txt"',
|
|
688
|
+
`session_mode=${shQuote(mode)}`,
|
|
689
|
+
'prev_session=""',
|
|
690
|
+
'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
|
|
691
|
+
] : [],
|
|
692
|
+
'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
|
|
693
|
+
'run_id="$(date +%s%N)-$$"',
|
|
694
|
+
"started=$(date +%s)",
|
|
695
|
+
'new_session=""',
|
|
696
|
+
'wt_branch=""',
|
|
697
|
+
'wt_commit=""',
|
|
698
|
+
`export OPENCODE_PERMISSION='{"question":"deny"}'`,
|
|
699
|
+
'export OPENCODE_JOBS_RUN_ID="$run_id"',
|
|
700
|
+
"finish() {",
|
|
701
|
+
' status="$1"',
|
|
702
|
+
' code="$2"',
|
|
703
|
+
" ended=$(date +%s)",
|
|
704
|
+
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"`,
|
|
705
|
+
"}",
|
|
706
|
+
"trap 'finish timeout 124; exit 124' TERM INT",
|
|
707
|
+
...job.guard === undefined ? [] : guardScriptLines(job.guard),
|
|
708
|
+
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"`,
|
|
709
|
+
...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
|
|
710
|
+
`oc_agent=${shQuote(job.run.agent ?? "")}`,
|
|
711
|
+
`oc_model=${shQuote(job.run.model ?? "")}`,
|
|
712
|
+
"prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
|
|
713
|
+
`oc_command=${shQuote("command" in job.run ? job.run.command : "")}`,
|
|
714
|
+
`oc_args=${shQuote("command" in job.run ? job.run.arguments ?? "" : "")}`,
|
|
715
|
+
`oc_prompt=${shQuote("prompt" in job.run ? job.run.prompt : "")}`,
|
|
716
|
+
`oc_keep_last=${String(isKeepLast ? 1 : 0)}`,
|
|
717
|
+
"run_opencode() {",
|
|
718
|
+
' sess="$1"',
|
|
719
|
+
' use_json="$2"',
|
|
720
|
+
" set -- run",
|
|
721
|
+
' if [ -n "$oc_agent" ]; then set -- "$@" --agent "$oc_agent"; fi',
|
|
722
|
+
' if [ -n "$oc_model" ]; then set -- "$@" --model "$oc_model"; fi',
|
|
723
|
+
' if [ -n "$sess" ]; then set -- "$@" --session "$sess"; fi',
|
|
724
|
+
' if [ "$use_json" -eq 1 ]; then set -- "$@" --format json; fi',
|
|
725
|
+
' if [ "$oc_command_mode" -eq 1 ]; then',
|
|
726
|
+
' set -- "$@" --command "$oc_command" -- "$oc_args"',
|
|
727
|
+
" else",
|
|
728
|
+
' set -- "$@" -- "$oc_prompt"',
|
|
729
|
+
" fi",
|
|
730
|
+
' "$oc_bin" "$@"',
|
|
731
|
+
"}",
|
|
732
|
+
...isCompact ? compactSessionLines() : [],
|
|
733
|
+
...isTracked ? [
|
|
734
|
+
'json_out="$(mktemp)"',
|
|
735
|
+
'run_opencode "$prev_session" 1 >"$json_out" 2>&1',
|
|
736
|
+
"code=$?",
|
|
737
|
+
'cat "$json_out"',
|
|
738
|
+
...extractSessionIdLines("new_session"),
|
|
739
|
+
'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
|
|
740
|
+
' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
|
|
741
|
+
' rm -f "$json_out"',
|
|
742
|
+
' json_out="$(mktemp)"',
|
|
743
|
+
' run_opencode "" 1 >"$json_out" 2>&1',
|
|
744
|
+
" code=$?",
|
|
745
|
+
' cat "$json_out"',
|
|
746
|
+
...extractSessionIdLines("new_session", " "),
|
|
747
|
+
"fi",
|
|
748
|
+
'cs_text=""',
|
|
749
|
+
'if [ "$oc_keep_last" -eq 1 ]; then',
|
|
750
|
+
` cs_text="$(awk '`,
|
|
751
|
+
' /"type":"text"/ {',
|
|
752
|
+
" line = $0",
|
|
753
|
+
String.raw` i = index(line, "\042text\042:\042")`,
|
|
754
|
+
" if (i == 0) next",
|
|
755
|
+
" i = i + 8",
|
|
756
|
+
' out = ""',
|
|
757
|
+
" len = length(line)",
|
|
758
|
+
" while (i <= len) {",
|
|
759
|
+
" c = substr(line, i, 1)",
|
|
760
|
+
String.raw` if (c == "\\") {`,
|
|
761
|
+
" out = out substr(line, i, 2)",
|
|
762
|
+
" i = i + 2",
|
|
763
|
+
" continue",
|
|
764
|
+
" }",
|
|
765
|
+
String.raw` if (c == "\042") break`,
|
|
766
|
+
" out = out c",
|
|
767
|
+
" i = i + 1",
|
|
768
|
+
" }",
|
|
769
|
+
" result = out",
|
|
770
|
+
" n = n + 1",
|
|
771
|
+
" }",
|
|
772
|
+
" END {",
|
|
773
|
+
" if (n > 0) {",
|
|
774
|
+
" if (length(result) > 16000) {",
|
|
775
|
+
" result = substr(result, 1, 16000)",
|
|
776
|
+
String.raw` if (substr(result, 16000, 1) == "\\") result = substr(result, 1, 15999)`,
|
|
777
|
+
" }",
|
|
778
|
+
" print result",
|
|
779
|
+
" }",
|
|
780
|
+
" }",
|
|
781
|
+
` ' "$json_out")"`,
|
|
782
|
+
"fi",
|
|
783
|
+
'rm -f "$json_out"',
|
|
784
|
+
'if [ -n "$new_session" ]; then',
|
|
785
|
+
String.raw` printf '%s\n' "$new_session" >"$state_file"`,
|
|
786
|
+
"fi"
|
|
787
|
+
] : ['run_opencode "" 0', "code=$?"],
|
|
788
|
+
"trap - TERM INT",
|
|
789
|
+
...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
|
|
790
|
+
'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
|
|
791
|
+
...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
|
|
792
|
+
"finish success 0",
|
|
793
|
+
"exit 0",
|
|
794
|
+
""
|
|
795
|
+
].join(`
|
|
796
|
+
`);
|
|
797
|
+
}
|
|
798
|
+
function serviceContent(job, options) {
|
|
799
|
+
const timeout = job.timeoutSeconds !== undefined && job.timeoutSeconds > 0 ? `TimeoutStartSec=${String(job.timeoutSeconds)}s` : "TimeoutStartSec=infinity";
|
|
800
|
+
const lines = [
|
|
801
|
+
"[Unit]",
|
|
802
|
+
`Description=OpenCode job: ${escapeUnitText(job.name)} (${job.slug})`,
|
|
803
|
+
"",
|
|
804
|
+
"[Service]",
|
|
805
|
+
"Type=oneshot",
|
|
806
|
+
`WorkingDirectory=${unitQuote(options.workdir)}`,
|
|
807
|
+
`Environment=${unitQuote(`PATH=${options.pathEnvironment}`)}`,
|
|
808
|
+
`ExecStart=/bin/sh ${unitQuote(options.runScript)}`,
|
|
809
|
+
timeout,
|
|
810
|
+
`StandardOutput=append:${unitQuote(options.log)}`,
|
|
811
|
+
`StandardError=append:${unitQuote(options.log)}`
|
|
812
|
+
];
|
|
813
|
+
return `${lines.join(`
|
|
814
|
+
`)}
|
|
815
|
+
`;
|
|
816
|
+
}
|
|
817
|
+
function timerContent(job, onCalendars) {
|
|
818
|
+
return [
|
|
819
|
+
"[Unit]",
|
|
820
|
+
`Description=OpenCode job timer: ${escapeUnitText(job.name)} (${job.slug})`,
|
|
821
|
+
"",
|
|
822
|
+
"[Timer]",
|
|
823
|
+
...onCalendars.map((calendar) => `OnCalendar=${calendar}`),
|
|
824
|
+
"Persistent=true",
|
|
825
|
+
"",
|
|
826
|
+
"[Install]",
|
|
827
|
+
"WantedBy=timers.target",
|
|
828
|
+
""
|
|
829
|
+
].join(`
|
|
830
|
+
`);
|
|
831
|
+
}
|
|
309
832
|
function systemctl(systemctlArguments) {
|
|
310
833
|
const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
|
|
311
834
|
encoding: "utf8"
|
|
@@ -316,30 +839,188 @@ function systemctl(systemctlArguments) {
|
|
|
316
839
|
stderr: result.stderr.trim()
|
|
317
840
|
};
|
|
318
841
|
}
|
|
842
|
+
function systemdHint(stderr) {
|
|
843
|
+
if (/failed to connect|not connected|dbus/i.test(stderr)) {
|
|
844
|
+
return `
|
|
845
|
+
Hint: no systemd user session is reachable. Over SSH try enabling lingering: loginctl enable-linger $USER`;
|
|
846
|
+
}
|
|
847
|
+
return "";
|
|
848
|
+
}
|
|
849
|
+
function isTimerLoaded(base) {
|
|
850
|
+
const result = systemctl([
|
|
851
|
+
"show",
|
|
852
|
+
timerUnit(base),
|
|
853
|
+
"-p",
|
|
854
|
+
"LoadState",
|
|
855
|
+
"--value"
|
|
856
|
+
]);
|
|
857
|
+
return result.ok && result.stdout !== "not-found";
|
|
858
|
+
}
|
|
859
|
+
function timerStatus(base) {
|
|
860
|
+
const result = systemctl([
|
|
861
|
+
"show",
|
|
862
|
+
timerUnit(base),
|
|
863
|
+
"-p",
|
|
864
|
+
"NextElapseUSecRealtime",
|
|
865
|
+
"-p",
|
|
866
|
+
"LastTriggerUSec",
|
|
867
|
+
"--value"
|
|
868
|
+
]);
|
|
869
|
+
if (!result.ok)
|
|
870
|
+
return { next: undefined, last: undefined };
|
|
871
|
+
const [next = "n/a", last = "n/a"] = result.stdout.split(`
|
|
872
|
+
`, 2);
|
|
873
|
+
return {
|
|
874
|
+
next: next === "n/a" ? undefined : next,
|
|
875
|
+
last: last === "n/a" ? undefined : last
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
|
|
879
|
+
const script = runScriptPath(scopeId, job.slug);
|
|
880
|
+
const base = unitBase(scopeId, job.slug);
|
|
881
|
+
mkdirSync2(logDirectory(scopeId), { recursive: true });
|
|
882
|
+
mkdirSync2(runsDirectory(scopeId), { recursive: true });
|
|
883
|
+
atomicWriteExecutable(script, runScriptContent(job, scopeId, opencodeBin));
|
|
884
|
+
const onCalendars = cronToOnCalendar(parseCron(job.schedule));
|
|
885
|
+
atomicWrite(path4.join(systemdUserDirectory(), timerUnit(base)), timerContent(job, onCalendars));
|
|
886
|
+
atomicWrite(path4.join(systemdUserDirectory(), serviceUnit(base)), serviceContent(job, {
|
|
887
|
+
workdir: path4.resolve(workdir),
|
|
888
|
+
runScript: script,
|
|
889
|
+
log: logFile(scopeId, job.slug),
|
|
890
|
+
pathEnvironment
|
|
891
|
+
}));
|
|
892
|
+
return base;
|
|
893
|
+
}
|
|
319
894
|
function removeJobUnits(scopeId, slug) {
|
|
320
895
|
const base = unitBase(scopeId, slug);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
896
|
+
const files = [
|
|
897
|
+
path4.join(systemdUserDirectory(), timerUnit(base)),
|
|
898
|
+
path4.join(systemdUserDirectory(), serviceUnit(base))
|
|
899
|
+
];
|
|
900
|
+
const script = runScriptPath(scopeId, slug);
|
|
901
|
+
if ([...files, script].every((file) => !existsSync2(file)) && !isTimerLoaded(base)) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const disable = systemctl(["disable", "--now", timerUnit(base)]);
|
|
905
|
+
if (!disable.ok) {
|
|
906
|
+
return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
|
|
907
|
+
}
|
|
908
|
+
for (const file of files) {
|
|
909
|
+
if (existsSync2(file))
|
|
327
910
|
rmSync(file);
|
|
328
911
|
}
|
|
329
|
-
|
|
330
|
-
if (existsSync(script))
|
|
912
|
+
if (existsSync2(script))
|
|
331
913
|
rmSync(script);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
function removeStaleUnits(scopeId, expectedSlugs) {
|
|
917
|
+
const prefix = `opencode-sched-${scopeId}-`;
|
|
918
|
+
const removed = [];
|
|
919
|
+
if (existsSync2(systemdUserDirectory())) {
|
|
920
|
+
for (const entry of readdirSync2(systemdUserDirectory())) {
|
|
921
|
+
if (!entry.startsWith(prefix))
|
|
922
|
+
continue;
|
|
923
|
+
if (!entry.endsWith(".service") && !entry.endsWith(".timer"))
|
|
924
|
+
continue;
|
|
925
|
+
const slug = entry.slice(prefix.length).replaceAll(/\.(service|timer)$/g, "");
|
|
926
|
+
if (!expectedSlugs.has(slug)) {
|
|
927
|
+
rmSync(path4.join(systemdUserDirectory(), entry));
|
|
928
|
+
removed.push(slug);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (existsSync2(scopeDirectory(scopeId))) {
|
|
933
|
+
const entries = readdirSync2(scopeDirectory(scopeId));
|
|
934
|
+
for (const entry of entries) {
|
|
935
|
+
const match = /^run-(.+)\.sh$/.exec(entry);
|
|
936
|
+
if (match === null)
|
|
937
|
+
continue;
|
|
938
|
+
const [, slug = ""] = match;
|
|
939
|
+
if (!expectedSlugs.has(slug))
|
|
940
|
+
rmSync(path4.join(scopeDirectory(scopeId), entry));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return removed;
|
|
332
944
|
}
|
|
333
945
|
|
|
334
946
|
// src/project.ts
|
|
947
|
+
function enableProject(workdir) {
|
|
948
|
+
if (process.platform !== "linux")
|
|
949
|
+
throw new Error("Scheduled jobs are only supported on Linux (systemd user units)");
|
|
950
|
+
const { jobs, errors } = loadJobs(workdir);
|
|
951
|
+
if (errors.length > 0)
|
|
952
|
+
throw new Error(`Invalid job definitions:
|
|
953
|
+
${errors.join(`
|
|
954
|
+
`)}`);
|
|
955
|
+
if (jobs.length === 0) {
|
|
956
|
+
throw new Error(`No job definitions found in ${jobsDirectory(workdir)}. Create one with schedule_job first.`);
|
|
957
|
+
}
|
|
958
|
+
const abs = path5.resolve(workdir);
|
|
959
|
+
const scopeId = deriveScopeId(abs);
|
|
960
|
+
const opencodeBin = findOpencode();
|
|
961
|
+
const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
962
|
+
const bases = jobs.map((job) => writeJobUnits(job, abs, scopeId, opencodeBin, pathEnvironment));
|
|
963
|
+
const removed = removeStaleUnits(scopeId, new Set(jobs.map((job) => job.slug)));
|
|
964
|
+
const reload = systemctl(["daemon-reload"]);
|
|
965
|
+
if (!reload.ok)
|
|
966
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
967
|
+
const failures = [];
|
|
968
|
+
for (const base of bases) {
|
|
969
|
+
const enable = systemctl(["enable", "--now", timerUnit(base)]);
|
|
970
|
+
if (!enable.ok)
|
|
971
|
+
failures.push(`${timerUnit(base)}: ${enable.stderr}${systemdHint(enable.stderr)}`);
|
|
972
|
+
}
|
|
973
|
+
const registry = loadRegistry();
|
|
974
|
+
const previous = registry.projects[abs];
|
|
975
|
+
registry.projects[abs] = {
|
|
976
|
+
scopeId,
|
|
977
|
+
workdir: abs,
|
|
978
|
+
enabledAt: previous?.enabledAt ?? nowIso(),
|
|
979
|
+
updatedAt: nowIso(),
|
|
980
|
+
jobs: jobs.map((job) => job.slug)
|
|
981
|
+
};
|
|
982
|
+
saveRegistry(registry);
|
|
983
|
+
const lines = [
|
|
984
|
+
`Enabled ${String(jobs.length)} job(s) for ${abs} (scope ${scopeId})`
|
|
985
|
+
];
|
|
986
|
+
if (removed.length > 0)
|
|
987
|
+
lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
|
|
988
|
+
lines.push(...describeJobSchedules(jobs, scopeId));
|
|
989
|
+
if (failures.length > 0)
|
|
990
|
+
throw new Error(`Timer activation failures:
|
|
991
|
+
${failures.join(`
|
|
992
|
+
`)}`);
|
|
993
|
+
return lines.join(`
|
|
994
|
+
`);
|
|
995
|
+
}
|
|
996
|
+
function describeJobSchedules(jobs, scopeId) {
|
|
997
|
+
const lines = [];
|
|
998
|
+
for (const job of jobs) {
|
|
999
|
+
const sets = parseCron(job.schedule);
|
|
1000
|
+
const next = timerStatus(unitBase(scopeId, job.slug)).next;
|
|
1001
|
+
const nextDesc = next === undefined ? "" : `, next: ${next}`;
|
|
1002
|
+
lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}`);
|
|
1003
|
+
}
|
|
1004
|
+
return lines;
|
|
1005
|
+
}
|
|
335
1006
|
function disableProject(workdir) {
|
|
336
|
-
const abs =
|
|
1007
|
+
const abs = path5.resolve(workdir);
|
|
337
1008
|
const entry = registryEntry(abs);
|
|
338
1009
|
if (entry === undefined)
|
|
339
1010
|
return `Project is not enabled: ${abs}`;
|
|
340
|
-
|
|
341
|
-
removeJobUnits(entry.scopeId, slug);
|
|
342
|
-
|
|
1011
|
+
const failures = entry.jobs.flatMap((slug) => {
|
|
1012
|
+
const failure = removeJobUnits(entry.scopeId, slug);
|
|
1013
|
+
return failure === undefined ? [] : [failure];
|
|
1014
|
+
});
|
|
1015
|
+
if (failures.length > 0) {
|
|
1016
|
+
throw new Error(`Timer removal failures:
|
|
1017
|
+
${failures.join(`
|
|
1018
|
+
`)}`);
|
|
1019
|
+
}
|
|
1020
|
+
const reload = systemctl(["daemon-reload"]);
|
|
1021
|
+
if (!reload.ok) {
|
|
1022
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
1023
|
+
}
|
|
343
1024
|
const registry = loadRegistry();
|
|
344
1025
|
const { [abs]: _omitted, ...remainingProjects } = registry.projects;
|
|
345
1026
|
registry.projects = remainingProjects;
|
|
@@ -352,6 +1033,82 @@ function disableProject(workdir) {
|
|
|
352
1033
|
`);
|
|
353
1034
|
}
|
|
354
1035
|
|
|
1036
|
+
// src/migration.ts
|
|
1037
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, renameSync as renameSync2, rmdirSync } from "node:fs";
|
|
1038
|
+
import path6 from "node:path";
|
|
1039
|
+
function legacyJobsStateDirectory() {
|
|
1040
|
+
return path6.join(configRoot(), "opencode", "scheduler");
|
|
1041
|
+
}
|
|
1042
|
+
function legacyRegistryPath() {
|
|
1043
|
+
return path6.join(legacyJobsStateDirectory(), "registry.json");
|
|
1044
|
+
}
|
|
1045
|
+
function legacyDefinitionsDirectory(workdir) {
|
|
1046
|
+
return path6.join(workdir, ".opencode", "scheduler", "jobs");
|
|
1047
|
+
}
|
|
1048
|
+
function migrationMoves(projects) {
|
|
1049
|
+
return [
|
|
1050
|
+
{
|
|
1051
|
+
from: legacyJobsStateDirectory(),
|
|
1052
|
+
to: jobsStateDirectory()
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
from: path6.join(configRoot(), "opencode", "logs", "scheduler"),
|
|
1056
|
+
to: path6.join(configRoot(), "opencode", "logs", "jobs")
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
from: path6.join(stateRoot(), "opencode", "scheduler", "worktrees"),
|
|
1060
|
+
to: path6.join(stateRoot(), "opencode", "jobs", "worktrees")
|
|
1061
|
+
},
|
|
1062
|
+
...[...projects].map((workdir) => ({
|
|
1063
|
+
from: legacyDefinitionsDirectory(workdir),
|
|
1064
|
+
to: jobsDirectory(workdir)
|
|
1065
|
+
}))
|
|
1066
|
+
];
|
|
1067
|
+
}
|
|
1068
|
+
function removeLegacyProjectDirectory(workdir) {
|
|
1069
|
+
try {
|
|
1070
|
+
rmdirSync(path6.join(workdir, ".opencode", "scheduler"));
|
|
1071
|
+
} catch {}
|
|
1072
|
+
}
|
|
1073
|
+
function migrateStorage(projectDirectory, shouldResync) {
|
|
1074
|
+
const project = path6.resolve(projectDirectory);
|
|
1075
|
+
const legacyRegistry = legacyRegistryPath();
|
|
1076
|
+
const canonicalRegistry = path6.join(jobsStateDirectory(), "registry.json");
|
|
1077
|
+
const registryFile = existsSync3(legacyRegistry) ? legacyRegistry : canonicalRegistry;
|
|
1078
|
+
const registry = existsSync3(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
|
|
1079
|
+
const registeredProjects = new Set(Object.keys(registry.projects));
|
|
1080
|
+
const projects = new Set([project, ...registeredProjects]);
|
|
1081
|
+
const moves = migrationMoves(projects).filter(({ from }) => existsSync3(from));
|
|
1082
|
+
for (const { from, to } of moves) {
|
|
1083
|
+
if (existsSync3(to)) {
|
|
1084
|
+
throw new Error(`Cannot migrate legacy job storage because both paths exist: ${from} and ${to}. Reconcile or back up one path, then retry; neither path was changed.`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
for (const { from, to } of moves) {
|
|
1088
|
+
mkdirSync3(path6.dirname(to), { recursive: true });
|
|
1089
|
+
renameSync2(from, to);
|
|
1090
|
+
}
|
|
1091
|
+
for (const workdir of projects)
|
|
1092
|
+
removeLegacyProjectDirectory(workdir);
|
|
1093
|
+
const result = {
|
|
1094
|
+
moved: moves,
|
|
1095
|
+
resyncedProjects: [],
|
|
1096
|
+
warnings: []
|
|
1097
|
+
};
|
|
1098
|
+
if (!shouldResync || moves.length === 0)
|
|
1099
|
+
return result;
|
|
1100
|
+
for (const workdir of registeredProjects) {
|
|
1101
|
+
try {
|
|
1102
|
+
enableProject(workdir);
|
|
1103
|
+
result.resyncedProjects.push(workdir);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1106
|
+
result.warnings.push(`${workdir}: ${message}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return result;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
355
1112
|
// src/install.ts
|
|
356
1113
|
var PACKAGE_NAME = "opencode-jobs";
|
|
357
1114
|
var SKILL_NAME = "opencode-jobs";
|
|
@@ -362,20 +1119,20 @@ var configSchema = z3.looseObject({
|
|
|
362
1119
|
var CONFIG_LOCATIONS = [
|
|
363
1120
|
"opencode.json",
|
|
364
1121
|
"opencode.jsonc",
|
|
365
|
-
|
|
366
|
-
|
|
1122
|
+
path7.join(".opencode", "opencode.json"),
|
|
1123
|
+
path7.join(".opencode", "opencode.jsonc")
|
|
367
1124
|
];
|
|
368
1125
|
function existingConfigPath(projectDirectory) {
|
|
369
1126
|
for (const relativePath of CONFIG_LOCATIONS) {
|
|
370
|
-
const configPath =
|
|
371
|
-
if (
|
|
1127
|
+
const configPath = path7.join(projectDirectory, relativePath);
|
|
1128
|
+
if (existsSync4(configPath))
|
|
372
1129
|
return configPath;
|
|
373
1130
|
}
|
|
374
1131
|
return;
|
|
375
1132
|
}
|
|
376
1133
|
function readConfig(configPath) {
|
|
377
1134
|
try {
|
|
378
|
-
const value = JSON.parse(
|
|
1135
|
+
const value = JSON.parse(readFileSync3(configPath, "utf8"));
|
|
379
1136
|
const result = configSchema.safeParse(value);
|
|
380
1137
|
return result.success ? result.data : undefined;
|
|
381
1138
|
} catch {
|
|
@@ -392,7 +1149,7 @@ function isPackageReference(entry) {
|
|
|
392
1149
|
}
|
|
393
1150
|
function installPluginConfig(projectDirectory) {
|
|
394
1151
|
const existingPath = existingConfigPath(projectDirectory);
|
|
395
|
-
const configPath = existingPath ??
|
|
1152
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
396
1153
|
if (existingPath === undefined) {
|
|
397
1154
|
atomicWrite(configPath, `${JSON.stringify({
|
|
398
1155
|
$schema: CONFIG_SCHEMA,
|
|
@@ -414,34 +1171,36 @@ function installPluginConfig(projectDirectory) {
|
|
|
414
1171
|
return { status: "added", configPath };
|
|
415
1172
|
}
|
|
416
1173
|
function installSkill(projectDirectory, packageDirectory) {
|
|
417
|
-
const sourcePath =
|
|
418
|
-
const skillPath =
|
|
419
|
-
const content =
|
|
420
|
-
if (
|
|
1174
|
+
const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
|
|
1175
|
+
const skillPath = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
|
|
1176
|
+
const content = readFileSync3(sourcePath, "utf8");
|
|
1177
|
+
if (existsSync4(skillPath) && readFileSync3(skillPath, "utf8") === content) {
|
|
421
1178
|
return { status: "unchanged", skillPath };
|
|
422
1179
|
}
|
|
423
1180
|
atomicWrite(skillPath, content);
|
|
424
1181
|
return { status: "written", skillPath };
|
|
425
1182
|
}
|
|
426
1183
|
function installProject(projectDirectory, packageDirectory) {
|
|
427
|
-
const resolvedProject =
|
|
428
|
-
if (!
|
|
1184
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1185
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
429
1186
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
430
1187
|
}
|
|
1188
|
+
const migration = migrateStorage(resolvedProject, true);
|
|
431
1189
|
return {
|
|
432
1190
|
projectDirectory: resolvedProject,
|
|
433
1191
|
plugin: installPluginConfig(resolvedProject),
|
|
434
|
-
skill: installSkill(resolvedProject, packageDirectory)
|
|
1192
|
+
skill: installSkill(resolvedProject, packageDirectory),
|
|
1193
|
+
...migration.moved.length > 0 && { migration }
|
|
435
1194
|
};
|
|
436
1195
|
}
|
|
437
1196
|
function removeDirectoryIfEmpty(directory) {
|
|
438
1197
|
try {
|
|
439
|
-
|
|
1198
|
+
rmdirSync2(directory);
|
|
440
1199
|
} catch {}
|
|
441
1200
|
}
|
|
442
1201
|
function uninstallPluginConfig(projectDirectory) {
|
|
443
1202
|
const existingPath = existingConfigPath(projectDirectory);
|
|
444
|
-
const configPath = existingPath ??
|
|
1203
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
445
1204
|
if (existingPath === undefined)
|
|
446
1205
|
return { status: "absent", configPath };
|
|
447
1206
|
const config = readConfig(configPath);
|
|
@@ -465,21 +1224,21 @@ function uninstallPluginConfig(projectDirectory) {
|
|
|
465
1224
|
return { status: "removed", configPath };
|
|
466
1225
|
}
|
|
467
1226
|
function uninstallSkill(projectDirectory, packageDirectory) {
|
|
468
|
-
const sourcePath =
|
|
469
|
-
const skillDirectory =
|
|
470
|
-
const skillPath =
|
|
471
|
-
if (!
|
|
1227
|
+
const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
|
|
1228
|
+
const skillDirectory = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
|
|
1229
|
+
const skillPath = path7.join(skillDirectory, "SKILL.md");
|
|
1230
|
+
if (!existsSync4(skillPath))
|
|
472
1231
|
return { status: "absent", skillPath };
|
|
473
|
-
if (
|
|
1232
|
+
if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
|
|
474
1233
|
return { status: "kept-modified", skillPath };
|
|
475
1234
|
}
|
|
476
1235
|
rmSync2(skillDirectory, { recursive: true, force: true });
|
|
477
|
-
removeDirectoryIfEmpty(
|
|
478
|
-
removeDirectoryIfEmpty(
|
|
1236
|
+
removeDirectoryIfEmpty(path7.dirname(skillDirectory));
|
|
1237
|
+
removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
|
|
479
1238
|
return { status: "removed", skillPath };
|
|
480
1239
|
}
|
|
481
1240
|
function purgeProjectData(projectDirectory) {
|
|
482
|
-
const abs =
|
|
1241
|
+
const abs = path7.resolve(projectDirectory);
|
|
483
1242
|
const scopeId = deriveScopeId(abs);
|
|
484
1243
|
const targets = [
|
|
485
1244
|
scopeDirectory(scopeId),
|
|
@@ -488,11 +1247,11 @@ function purgeProjectData(projectDirectory) {
|
|
|
488
1247
|
logDirectory(scopeId),
|
|
489
1248
|
locksDirectory(scopeId),
|
|
490
1249
|
worktreesDirectory(scopeId),
|
|
491
|
-
|
|
1250
|
+
path7.join(abs, ".opencode", "jobs")
|
|
492
1251
|
];
|
|
493
1252
|
const paths = [];
|
|
494
1253
|
for (const target of targets) {
|
|
495
|
-
if (!
|
|
1254
|
+
if (!existsSync4(target))
|
|
496
1255
|
continue;
|
|
497
1256
|
rmSync2(target, { recursive: true, force: true });
|
|
498
1257
|
paths.push(target);
|
|
@@ -500,14 +1259,15 @@ function purgeProjectData(projectDirectory) {
|
|
|
500
1259
|
if (paths.includes(worktreesDirectory(scopeId))) {
|
|
501
1260
|
spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
|
|
502
1261
|
}
|
|
503
|
-
removeDirectoryIfEmpty(
|
|
1262
|
+
removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
|
|
504
1263
|
return { paths };
|
|
505
1264
|
}
|
|
506
1265
|
function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
507
|
-
const resolvedProject =
|
|
508
|
-
if (!
|
|
1266
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1267
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
509
1268
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
510
1269
|
}
|
|
1270
|
+
const migration = migrateStorage(resolvedProject, false);
|
|
511
1271
|
const wasEnabled = registryEntry(resolvedProject) !== undefined;
|
|
512
1272
|
if (wasEnabled)
|
|
513
1273
|
disableProject(resolvedProject);
|
|
@@ -515,30 +1275,213 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
|
515
1275
|
projectDirectory: resolvedProject,
|
|
516
1276
|
disabled: wasEnabled,
|
|
517
1277
|
plugin: uninstallPluginConfig(resolvedProject),
|
|
518
|
-
skill: uninstallSkill(resolvedProject, packageDirectory)
|
|
1278
|
+
skill: uninstallSkill(resolvedProject, packageDirectory),
|
|
1279
|
+
...migration.moved.length > 0 && { migration }
|
|
519
1280
|
};
|
|
520
1281
|
if (shouldPurge)
|
|
521
1282
|
uninstall.purge = purgeProjectData(resolvedProject);
|
|
522
1283
|
return uninstall;
|
|
523
1284
|
}
|
|
524
1285
|
|
|
1286
|
+
// src/management.ts
|
|
1287
|
+
import { spawn } from "node:child_process";
|
|
1288
|
+
import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync4, openSync } from "node:fs";
|
|
1289
|
+
import path8 from "node:path";
|
|
1290
|
+
|
|
1291
|
+
// src/runs.ts
|
|
1292
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
|
|
1293
|
+
import { z as z4 } from "zod";
|
|
1294
|
+
var optionalString = z4.string().optional().catch(undefined);
|
|
1295
|
+
var optionalNumber = z4.number().optional().catch(undefined);
|
|
1296
|
+
var runRecordSchema = z4.object({
|
|
1297
|
+
runId: optionalString,
|
|
1298
|
+
slug: optionalString,
|
|
1299
|
+
scopeId: optionalString,
|
|
1300
|
+
startedAt: optionalNumber,
|
|
1301
|
+
finishedAt: optionalNumber,
|
|
1302
|
+
durationMs: optionalNumber,
|
|
1303
|
+
status: optionalString,
|
|
1304
|
+
exitCode: optionalNumber,
|
|
1305
|
+
sessionId: optionalString,
|
|
1306
|
+
startedBy: optionalString,
|
|
1307
|
+
worktreeBranch: optionalString,
|
|
1308
|
+
worktreeCommit: optionalString
|
|
1309
|
+
});
|
|
1310
|
+
function readRunRecords(scopeId, slug, limit) {
|
|
1311
|
+
const file = runsFile(scopeId, slug);
|
|
1312
|
+
if (!existsSync5(file))
|
|
1313
|
+
return [];
|
|
1314
|
+
const records = [];
|
|
1315
|
+
for (const line of readFileSync4(file, "utf8").split(`
|
|
1316
|
+
`)) {
|
|
1317
|
+
if (line.trim().length === 0)
|
|
1318
|
+
continue;
|
|
1319
|
+
try {
|
|
1320
|
+
const value = JSON.parse(line);
|
|
1321
|
+
const result = runRecordSchema.safeParse(value);
|
|
1322
|
+
if (result.success)
|
|
1323
|
+
records.push(result.data);
|
|
1324
|
+
} catch {}
|
|
1325
|
+
}
|
|
1326
|
+
return records.slice(-limit);
|
|
1327
|
+
}
|
|
1328
|
+
function lastFinishedRun(records) {
|
|
1329
|
+
for (const record of records.toReversed()) {
|
|
1330
|
+
if (record.status !== undefined && record.status !== "running")
|
|
1331
|
+
return record;
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
function timestampOf(record) {
|
|
1336
|
+
if (record.finishedAt !== undefined)
|
|
1337
|
+
return new Date(record.finishedAt * 1000).toISOString();
|
|
1338
|
+
if (record.startedAt !== undefined)
|
|
1339
|
+
return new Date(record.startedAt * 1000).toISOString();
|
|
1340
|
+
return "?";
|
|
1341
|
+
}
|
|
1342
|
+
function formatRunLine(record) {
|
|
1343
|
+
const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
|
|
1344
|
+
const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
|
|
1345
|
+
const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
|
|
1346
|
+
const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
|
|
1347
|
+
return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
|
|
1348
|
+
}
|
|
1349
|
+
function tailFile(file, lines, maxChars) {
|
|
1350
|
+
if (!existsSync5(file))
|
|
1351
|
+
return;
|
|
1352
|
+
const content = readFileSync4(file, "utf8").trimEnd();
|
|
1353
|
+
if (content.length === 0)
|
|
1354
|
+
return "";
|
|
1355
|
+
const tail = content.split(`
|
|
1356
|
+
`).slice(-lines).join(`
|
|
1357
|
+
`);
|
|
1358
|
+
return tail.length > maxChars ? `...${tail.slice(-maxChars)}` : tail;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/management.ts
|
|
1362
|
+
function tryParseCron(schedule) {
|
|
1363
|
+
try {
|
|
1364
|
+
return parseCron(schedule);
|
|
1365
|
+
} catch {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function listJobs(directory) {
|
|
1370
|
+
const { jobs, errors } = loadJobs(directory);
|
|
1371
|
+
const entry = registryEntry(directory);
|
|
1372
|
+
const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
|
|
1373
|
+
const lines = [header];
|
|
1374
|
+
if (jobs.length === 0)
|
|
1375
|
+
lines.push("No job definitions. Create one with schedule_job.");
|
|
1376
|
+
for (const job of jobs) {
|
|
1377
|
+
const scopeId = entry?.scopeId ?? deriveScopeId(directory);
|
|
1378
|
+
const sets = tryParseCron(job.schedule);
|
|
1379
|
+
if (sets === undefined) {
|
|
1380
|
+
lines.push(`- ${job.slug}: INVALID schedule "${job.schedule}"`);
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const records = readRunRecords(scopeId, job.slug, 20);
|
|
1384
|
+
const last = lastFinishedRun(records);
|
|
1385
|
+
const lastDesc = last === undefined ? ", last: never" : `, last: ${last.status ?? "?"} ${formatRunLine(last)}`;
|
|
1386
|
+
const next = entry === undefined ? undefined : timerStatus(unitBase(scopeId, job.slug)).next;
|
|
1387
|
+
const nextDesc = next === undefined ? "" : `, next: ${next}`;
|
|
1388
|
+
lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
|
|
1389
|
+
}
|
|
1390
|
+
lines.push(...errors.map((error) => `! ${error}`));
|
|
1391
|
+
return { ok: true, output: lines.join(`
|
|
1392
|
+
`) };
|
|
1393
|
+
}
|
|
1394
|
+
function runJobNow(slugInput, directory) {
|
|
1395
|
+
const slug = slugify(slugInput);
|
|
1396
|
+
const file = path8.join(jobsDirectory(directory), `${slug}.json`);
|
|
1397
|
+
if (!existsSync6(file)) {
|
|
1398
|
+
return {
|
|
1399
|
+
ok: false,
|
|
1400
|
+
output: `No job "${slug}" in ${jobsDirectory(directory)}`
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
const entry = registryEntry(directory);
|
|
1404
|
+
if (entry === undefined) {
|
|
1405
|
+
return {
|
|
1406
|
+
ok: false,
|
|
1407
|
+
output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const script = runScriptPath(entry.scopeId, slug);
|
|
1411
|
+
if (!existsSync6(script)) {
|
|
1412
|
+
return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
const log = logFile(entry.scopeId, slug);
|
|
1418
|
+
mkdirSync4(logDirectory(entry.scopeId), { recursive: true });
|
|
1419
|
+
const fd = openSync(log, "a");
|
|
1420
|
+
const child = spawn("/bin/sh", [script], {
|
|
1421
|
+
cwd: path8.resolve(directory),
|
|
1422
|
+
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1423
|
+
stdio: ["ignore", fd, fd]
|
|
1424
|
+
});
|
|
1425
|
+
child.unref();
|
|
1426
|
+
closeSync(fd);
|
|
1427
|
+
const tail = tailFile(log, 5, 2000);
|
|
1428
|
+
const parts = [
|
|
1429
|
+
`Started "${slug}" manually (pid ${String(child.pid)})`,
|
|
1430
|
+
`Log: ${log}`
|
|
1431
|
+
];
|
|
1432
|
+
if (tail?.length)
|
|
1433
|
+
parts.push(`Log tail:
|
|
1434
|
+
${tail}`);
|
|
1435
|
+
return { ok: true, output: parts.join(`
|
|
1436
|
+
`) };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
525
1439
|
// src/cli.ts
|
|
526
1440
|
var USAGE = `Usage: opencode-jobs <command> [projectDir]
|
|
527
1441
|
|
|
528
1442
|
Commands:
|
|
529
1443
|
install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
|
|
530
1444
|
uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
|
|
531
|
-
--purge also deletes job definitions and
|
|
1445
|
+
--purge also deletes job definitions and job data
|
|
1446
|
+
list [projectDir] List jobs and their enabled, next-run, and last-run state
|
|
1447
|
+
enable [projectDir] Enable or re-sync all jobs in a project
|
|
1448
|
+
disable [projectDir] Disable all jobs in a project while keeping definitions and history
|
|
1449
|
+
run <slug> [projectDir] Run one enabled job immediately
|
|
532
1450
|
help Show this help`;
|
|
533
1451
|
function packageDirectory() {
|
|
534
|
-
return
|
|
1452
|
+
return path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
|
|
535
1453
|
}
|
|
536
1454
|
function printError(message) {
|
|
537
|
-
console.
|
|
538
|
-
|
|
539
|
-
${USAGE}`);
|
|
1455
|
+
console.log(JSON.stringify({ ok: false, output: `Error: ${message}` }, undefined, 2));
|
|
1456
|
+
console.error(USAGE);
|
|
540
1457
|
process.exitCode = 1;
|
|
541
1458
|
}
|
|
1459
|
+
function projectArgument(command, arguments_) {
|
|
1460
|
+
if (arguments_.some((argument) => argument.startsWith("--"))) {
|
|
1461
|
+
throw new Error(`${command} does not accept options`);
|
|
1462
|
+
}
|
|
1463
|
+
if (arguments_.length > 1) {
|
|
1464
|
+
throw new Error(`${command} accepts at most one project directory`);
|
|
1465
|
+
}
|
|
1466
|
+
return arguments_[0] ?? process.cwd();
|
|
1467
|
+
}
|
|
1468
|
+
function runArguments(arguments_) {
|
|
1469
|
+
if (arguments_.some((argument) => argument.startsWith("--"))) {
|
|
1470
|
+
throw new Error("run does not accept options");
|
|
1471
|
+
}
|
|
1472
|
+
if (arguments_.length === 0 || arguments_.length > 2) {
|
|
1473
|
+
throw new Error("run requires a job slug and accepts one project directory");
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
slug: arguments_[0] ?? "",
|
|
1477
|
+
project: arguments_[1] ?? process.cwd()
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function printManagementResult(result) {
|
|
1481
|
+
console.log(JSON.stringify(result, undefined, 2));
|
|
1482
|
+
if (!result.ok)
|
|
1483
|
+
process.exitCode = 1;
|
|
1484
|
+
}
|
|
542
1485
|
function parseUninstallArguments(arguments_) {
|
|
543
1486
|
let project;
|
|
544
1487
|
let shouldPurge = false;
|
|
@@ -588,6 +1531,43 @@ if ([undefined, "help", "--help"].includes(command)) {
|
|
|
588
1531
|
} catch (error) {
|
|
589
1532
|
printError(errorMessage(error));
|
|
590
1533
|
}
|
|
1534
|
+
} else if (command !== undefined && ["list", "enable", "disable", "run"].includes(command)) {
|
|
1535
|
+
try {
|
|
1536
|
+
let slug;
|
|
1537
|
+
let project;
|
|
1538
|
+
if (command === "run") {
|
|
1539
|
+
({ slug, project } = runArguments(rest));
|
|
1540
|
+
} else {
|
|
1541
|
+
project = projectArgument(command, rest);
|
|
1542
|
+
}
|
|
1543
|
+
const migration = migrateStorage(project, true);
|
|
1544
|
+
for (const warning of migration.warnings) {
|
|
1545
|
+
console.error(`storage migration warning: ${warning}`);
|
|
1546
|
+
}
|
|
1547
|
+
switch (command) {
|
|
1548
|
+
case "list": {
|
|
1549
|
+
printManagementResult(listJobs(project));
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
case "enable": {
|
|
1553
|
+
printManagementResult({ ok: true, output: enableProject(project) });
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
case "disable": {
|
|
1557
|
+
printManagementResult({ ok: true, output: disableProject(project) });
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
case "run": {
|
|
1561
|
+
printManagementResult(runJobNow(slug ?? "", project));
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
default: {
|
|
1565
|
+
throw new Error(`unknown management command: ${command}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
printError(errorMessage(error));
|
|
1570
|
+
}
|
|
591
1571
|
} else {
|
|
592
1572
|
printError(`unknown command: ${command ?? "(missing)"}`);
|
|
593
1573
|
}
|