opencode-jobs 0.2.0 → 1.0.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 +28 -18
- package/dist/cli.js +795 -65
- package/dist/index.d.ts +1 -1
- package/dist/index.js +151 -60
- package/dist/install.d.ts +3 -0
- package/dist/migration.d.ts +9 -0
- package/dist/paths.d.ts +1 -1
- package/dist/registry.d.ts +1 -0
- 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 path8 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,38 @@ 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
41
|
}
|
|
42
42
|
function sessionStateDirectory(scopeId) {
|
|
43
|
-
return path.join(
|
|
43
|
+
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
44
44
|
}
|
|
45
45
|
function logDirectory(scopeId) {
|
|
46
|
-
return path.join(configRoot(), "opencode", "logs", "
|
|
46
|
+
return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
|
|
47
|
+
}
|
|
48
|
+
function logFile(scopeId, slug) {
|
|
49
|
+
return path.join(logDirectory(scopeId), `${slug}.log`);
|
|
47
50
|
}
|
|
48
51
|
function systemdUserDirectory() {
|
|
49
52
|
return path.join(configRoot(), "systemd", "user");
|
|
50
53
|
}
|
|
54
|
+
function jobsDirectory(workdir) {
|
|
55
|
+
return path.join(workdir, ".opencode", "jobs");
|
|
56
|
+
}
|
|
51
57
|
function unitBase(scopeId, slug) {
|
|
52
58
|
return `opencode-sched-${scopeId}-${slug}`;
|
|
53
59
|
}
|
|
@@ -64,12 +70,30 @@ function slugify(input) {
|
|
|
64
70
|
const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
|
|
65
71
|
return slug.length > 0 ? slug : "job";
|
|
66
72
|
}
|
|
73
|
+
function shQuote(value) {
|
|
74
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
75
|
+
}
|
|
76
|
+
function unitQuote(value) {
|
|
77
|
+
if (/^[A-Za-z0-9_@:=./-]*$/.test(value))
|
|
78
|
+
return value;
|
|
79
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', String.raw`\"`)}"`;
|
|
80
|
+
}
|
|
81
|
+
function escapeUnitText(value) {
|
|
82
|
+
return value.replaceAll("%", "%%").replaceAll(/\s+/g, " ").trim();
|
|
83
|
+
}
|
|
67
84
|
function atomicWrite(file, content) {
|
|
68
85
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
69
86
|
const temporary = `${file}.tmp`;
|
|
70
87
|
writeFileSync(temporary, content);
|
|
71
88
|
renameSync(temporary, file);
|
|
72
89
|
}
|
|
90
|
+
function atomicWriteExecutable(file, content) {
|
|
91
|
+
atomicWrite(file, content);
|
|
92
|
+
chmodSync(file, 493);
|
|
93
|
+
}
|
|
94
|
+
function nowIso() {
|
|
95
|
+
return new Date().toISOString();
|
|
96
|
+
}
|
|
73
97
|
function deriveScopeId(workdir) {
|
|
74
98
|
const abs = path.resolve(workdir);
|
|
75
99
|
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
|
|
@@ -77,12 +101,15 @@ function deriveScopeId(workdir) {
|
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
// src/project.ts
|
|
80
|
-
import
|
|
104
|
+
import path5 from "node:path";
|
|
81
105
|
|
|
82
106
|
// src/job.ts
|
|
107
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
108
|
+
import path2 from "node:path";
|
|
83
109
|
import { z } from "zod";
|
|
84
110
|
|
|
85
111
|
// src/cron.ts
|
|
112
|
+
var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
86
113
|
var DOW_NAMES = {
|
|
87
114
|
sun: 0,
|
|
88
115
|
mon: 1,
|
|
@@ -167,6 +194,42 @@ function parseCron(expression) {
|
|
|
167
194
|
dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
|
|
168
195
|
};
|
|
169
196
|
}
|
|
197
|
+
function pad2(value) {
|
|
198
|
+
return String(value).padStart(2, "0");
|
|
199
|
+
}
|
|
200
|
+
function fmtList(values) {
|
|
201
|
+
return values.map((v) => pad2(v)).join(",");
|
|
202
|
+
}
|
|
203
|
+
function cronToOnCalendar(sets) {
|
|
204
|
+
const timePart = `${fmtList(sets.hour)}:${fmtList(sets.minute)}:00`;
|
|
205
|
+
const months = sets.month.length === 12 ? "*" : fmtList(sets.month);
|
|
206
|
+
const doms = sets.dom.length === 31 ? "*" : fmtList(sets.dom);
|
|
207
|
+
const dows = sets.dow.length === 7 ? "" : `${sets.dow.map((d) => DOW_LABELS[d]).join(",")} `;
|
|
208
|
+
if (sets.dow.length === 7)
|
|
209
|
+
return [`*-${months}-${doms} ${timePart}`];
|
|
210
|
+
if (sets.dom.length === 31 && sets.month.length === 12)
|
|
211
|
+
return [`${dows}*-*-* ${timePart}`];
|
|
212
|
+
return [
|
|
213
|
+
`${dows}*-${months}-* ${timePart}`,
|
|
214
|
+
`*-${months}-${doms} ${timePart}`
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
function describeCron(sets) {
|
|
218
|
+
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(",")}`;
|
|
219
|
+
const isDowAll = sets.dow.length === 7;
|
|
220
|
+
const isDomAll = sets.dom.length === 31;
|
|
221
|
+
let dayDesc;
|
|
222
|
+
if (isDowAll && isDomAll)
|
|
223
|
+
dayDesc = "every day";
|
|
224
|
+
else if (isDowAll)
|
|
225
|
+
dayDesc = `on day ${sets.dom.join(",")} of the month`;
|
|
226
|
+
else if (isDomAll)
|
|
227
|
+
dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")}`;
|
|
228
|
+
else
|
|
229
|
+
dayDesc = `on ${sets.dow.map((d) => DOW_LABELS[d]).join(",")} or day ${sets.dom.join(",")}`;
|
|
230
|
+
const monthDesc = sets.month.length === 12 ? "" : ` in month ${sets.month.join(",")}`;
|
|
231
|
+
return `${timeDesc} ${dayDesc}${monthDesc}`;
|
|
232
|
+
}
|
|
170
233
|
|
|
171
234
|
// src/json.ts
|
|
172
235
|
function errorMessage(error) {
|
|
@@ -258,10 +321,82 @@ var jobFileSchema = z.strictObject({
|
|
|
258
321
|
createdAt: z.string().optional(),
|
|
259
322
|
updatedAt: z.string().optional()
|
|
260
323
|
});
|
|
324
|
+
function formatValidationError(error) {
|
|
325
|
+
const issue = error.issues[0];
|
|
326
|
+
if (issue === undefined)
|
|
327
|
+
return "invalid job definition";
|
|
328
|
+
const field = issue.path.join(".");
|
|
329
|
+
return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
|
|
330
|
+
}
|
|
331
|
+
function loadJobFile(file, expectedSlug) {
|
|
332
|
+
const stem = path2.basename(file, ".json");
|
|
333
|
+
try {
|
|
334
|
+
const object = JSON.parse(readFileSync(file, "utf8"));
|
|
335
|
+
const result = jobFileSchema.safeParse(object);
|
|
336
|
+
if (!result.success) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
error: `${stem}.json: ${formatValidationError(result.error)}`
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
const definition = result.data;
|
|
343
|
+
const slug = definition.slug ?? stem;
|
|
344
|
+
if (slug !== stem)
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: `${stem}.json: "slug" ("${slug}") must match filename`
|
|
348
|
+
};
|
|
349
|
+
if (expectedSlug !== undefined && slug !== expectedSlug)
|
|
350
|
+
return { ok: false, error: `${stem}.json: unexpected slug` };
|
|
351
|
+
const timestamp = nowIso();
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
job: {
|
|
355
|
+
slug,
|
|
356
|
+
name: definition.name,
|
|
357
|
+
schedule: definition.schedule,
|
|
358
|
+
run: definition.run,
|
|
359
|
+
...definition.session !== "new" && { session: definition.session },
|
|
360
|
+
...definition.guard !== undefined && { guard: definition.guard },
|
|
361
|
+
...definition.worktree !== undefined && {
|
|
362
|
+
worktree: definition.worktree
|
|
363
|
+
},
|
|
364
|
+
...definition.timeoutSeconds !== undefined && {
|
|
365
|
+
timeoutSeconds: definition.timeoutSeconds
|
|
366
|
+
},
|
|
367
|
+
createdAt: definition.createdAt ?? timestamp,
|
|
368
|
+
updatedAt: definition.updatedAt ?? timestamp
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function loadJobs(workdir) {
|
|
376
|
+
const directory = jobsDirectory(workdir);
|
|
377
|
+
if (!existsSync(directory))
|
|
378
|
+
return { jobs: [], errors: [] };
|
|
379
|
+
const jobs = [];
|
|
380
|
+
const errors = [];
|
|
381
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
384
|
+
continue;
|
|
385
|
+
const result = loadJobFile(path2.join(directory, entry.name));
|
|
386
|
+
if (result.ok)
|
|
387
|
+
jobs.push(result.job);
|
|
388
|
+
else
|
|
389
|
+
errors.push(result.error);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
jobs: jobs.toSorted((a, b) => a.slug.localeCompare(b.slug)),
|
|
393
|
+
errors
|
|
394
|
+
};
|
|
395
|
+
}
|
|
261
396
|
|
|
262
397
|
// src/registry.ts
|
|
263
|
-
import { readFileSync } from "node:fs";
|
|
264
|
-
import
|
|
398
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
399
|
+
import path3 from "node:path";
|
|
265
400
|
import { z as z2 } from "zod";
|
|
266
401
|
var registryEntrySchema = z2.looseObject({
|
|
267
402
|
scopeId: z2.string(),
|
|
@@ -274,19 +409,22 @@ var registryFileSchema = z2.object({
|
|
|
274
409
|
version: z2.literal(1),
|
|
275
410
|
projects: z2.record(z2.string(), z2.unknown())
|
|
276
411
|
});
|
|
412
|
+
function readRegistryFile(file) {
|
|
413
|
+
const parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
414
|
+
const result = registryFileSchema.safeParse(parsed);
|
|
415
|
+
if (!result.success)
|
|
416
|
+
throw new Error(`Invalid job registry: ${file}`);
|
|
417
|
+
const projects = {};
|
|
418
|
+
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
419
|
+
const entry = registryEntrySchema.safeParse(value);
|
|
420
|
+
if (entry.success)
|
|
421
|
+
projects[key] = entry.data;
|
|
422
|
+
}
|
|
423
|
+
return { version: 1, projects };
|
|
424
|
+
}
|
|
277
425
|
function loadRegistry() {
|
|
278
426
|
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 };
|
|
427
|
+
return readRegistryFile(registryPath());
|
|
290
428
|
} catch {
|
|
291
429
|
return { version: 1, projects: {} };
|
|
292
430
|
}
|
|
@@ -296,16 +434,398 @@ function saveRegistry(registry) {
|
|
|
296
434
|
`);
|
|
297
435
|
}
|
|
298
436
|
function registryEntry(workdir) {
|
|
299
|
-
return loadRegistry().projects[
|
|
437
|
+
return loadRegistry().projects[path3.resolve(workdir)];
|
|
300
438
|
}
|
|
301
439
|
|
|
302
440
|
// src/systemd.ts
|
|
303
|
-
import { existsSync, mkdirSync as mkdirSync2, readdirSync, rmSync } from "node:fs";
|
|
441
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync } from "node:fs";
|
|
304
442
|
import { spawnSync } from "node:child_process";
|
|
305
|
-
import
|
|
443
|
+
import path4 from "node:path";
|
|
444
|
+
import { homedir as homedir2 } from "node:os";
|
|
445
|
+
function findOpencode() {
|
|
446
|
+
const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
447
|
+
if (override !== undefined && override.length > 0)
|
|
448
|
+
return override;
|
|
449
|
+
const which = spawnSync("sh", ["-c", "command -v opencode"], {
|
|
450
|
+
encoding: "utf8"
|
|
451
|
+
});
|
|
452
|
+
const onPath = which.stdout.trim();
|
|
453
|
+
if (which.status === 0 && onPath.length > 0)
|
|
454
|
+
return onPath;
|
|
455
|
+
const candidates = [
|
|
456
|
+
path4.join(homedir2(), ".opencode/bin/opencode"),
|
|
457
|
+
"/usr/local/bin/opencode",
|
|
458
|
+
"/usr/bin/opencode"
|
|
459
|
+
];
|
|
460
|
+
for (const candidate of candidates) {
|
|
461
|
+
if (existsSync2(candidate))
|
|
462
|
+
return candidate;
|
|
463
|
+
}
|
|
464
|
+
return "opencode";
|
|
465
|
+
}
|
|
466
|
+
function guardScriptLines(guard) {
|
|
467
|
+
return [
|
|
468
|
+
`guard=${shQuote(guard)}`,
|
|
469
|
+
'sh -c "$guard"',
|
|
470
|
+
"guard_code=$?",
|
|
471
|
+
'if [ "$guard_code" -ne 0 ]; then',
|
|
472
|
+
' echo "guard exited $guard_code, skipping run"',
|
|
473
|
+
' finish skipped "$guard_code"',
|
|
474
|
+
" exit 0",
|
|
475
|
+
"fi"
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
function worktreeDefaultRoot(scopeId) {
|
|
479
|
+
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
|
|
480
|
+
}
|
|
481
|
+
function worktreePrologueLines(job, scopeId) {
|
|
482
|
+
const base = job.worktree?.base;
|
|
483
|
+
return [
|
|
484
|
+
"wt_enabled=1",
|
|
485
|
+
'orig_pwd="$(pwd)"',
|
|
486
|
+
'lock_dir="$config_root/opencode/jobs/locks/$scope"',
|
|
487
|
+
'mkdir -p "$lock_dir"',
|
|
488
|
+
'exec 9>"$lock_dir/$slug.lock"',
|
|
489
|
+
"if ! flock -n 9; then",
|
|
490
|
+
' echo "opencode-jobs: another run of $slug is already active, skipping"',
|
|
491
|
+
" finish skipped 0",
|
|
492
|
+
" exit 0",
|
|
493
|
+
"fi",
|
|
494
|
+
base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
|
|
495
|
+
'if ! mkdir -p "$wt_root"; then',
|
|
496
|
+
' echo "opencode-jobs: cannot create worktree base $wt_root"',
|
|
497
|
+
" finish failed 1",
|
|
498
|
+
" exit 1",
|
|
499
|
+
"fi",
|
|
500
|
+
'wt_root="$(cd "$wt_root" && pwd)"',
|
|
501
|
+
'wt_path="$wt_root/$slug"',
|
|
502
|
+
'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
|
|
503
|
+
`wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
|
|
504
|
+
'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
|
|
505
|
+
'wt_sub="${wt_sub%/}"',
|
|
506
|
+
'if [ -d "$wt_path" ]; then',
|
|
507
|
+
' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
|
|
508
|
+
" wt_stale_saved=1",
|
|
509
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
|
|
510
|
+
' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
511
|
+
' 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',
|
|
512
|
+
" fi",
|
|
513
|
+
' if [ "$wt_stale_saved" -eq 1 ]; then',
|
|
514
|
+
' git worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
515
|
+
' rm -rf "$wt_path"',
|
|
516
|
+
" else",
|
|
517
|
+
' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
|
|
518
|
+
' wt_branch=""',
|
|
519
|
+
" finish failed 1",
|
|
520
|
+
" exit 1",
|
|
521
|
+
" fi",
|
|
522
|
+
" else",
|
|
523
|
+
' echo "opencode-jobs: removing unexpected directory at $wt_path"',
|
|
524
|
+
' rm -rf "$wt_path"',
|
|
525
|
+
" fi",
|
|
526
|
+
" git worktree prune >/dev/null 2>&1",
|
|
527
|
+
"fi",
|
|
528
|
+
'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
|
|
529
|
+
' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
|
|
530
|
+
' wt_branch=""',
|
|
531
|
+
" finish failed 1",
|
|
532
|
+
" exit 1",
|
|
533
|
+
"fi",
|
|
534
|
+
'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
|
|
535
|
+
' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
|
|
536
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
537
|
+
' rm -rf "$wt_path"',
|
|
538
|
+
' wt_branch=""',
|
|
539
|
+
" finish failed 1",
|
|
540
|
+
" exit 1",
|
|
541
|
+
"fi",
|
|
542
|
+
'if [ -n "$wt_sub" ]; then',
|
|
543
|
+
' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
|
|
544
|
+
"else",
|
|
545
|
+
' cd "$wt_path" || { finish failed 1; exit 1; }',
|
|
546
|
+
"fi"
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
function worktreeEpilogueLines(options) {
|
|
550
|
+
const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
|
|
551
|
+
return [
|
|
552
|
+
'if [ "$wt_enabled" -eq 1 ]; then',
|
|
553
|
+
` wt_msg=${message}`,
|
|
554
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1',
|
|
555
|
+
" wt_keep=0",
|
|
556
|
+
' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
557
|
+
' 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',
|
|
558
|
+
' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
|
|
559
|
+
" else",
|
|
560
|
+
' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
|
|
561
|
+
" wt_keep=1",
|
|
562
|
+
" fi",
|
|
563
|
+
" fi",
|
|
564
|
+
' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
|
|
565
|
+
' if [ "$wt_keep" -eq 0 ]; then',
|
|
566
|
+
' cd "$wt_root" 2>/dev/null',
|
|
567
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
|
|
568
|
+
' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
|
|
569
|
+
" fi",
|
|
570
|
+
"fi"
|
|
571
|
+
];
|
|
572
|
+
}
|
|
306
573
|
var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
|
|
307
574
|
var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
308
575
|
var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
|
|
576
|
+
function extractSessionIdLines(target, indent = "") {
|
|
577
|
+
return [
|
|
578
|
+
`${indent}${target}="$(sed -n '${SESSION_ID_SED}' "$json_out" | head -n 1)"`
|
|
579
|
+
];
|
|
580
|
+
}
|
|
581
|
+
function compactSessionLines() {
|
|
582
|
+
return [
|
|
583
|
+
"compact_session() {",
|
|
584
|
+
' csid="$1"',
|
|
585
|
+
" if ! command -v curl >/dev/null 2>&1; then",
|
|
586
|
+
' echo "opencode-jobs: curl not available, skipping compaction"',
|
|
587
|
+
" return 0",
|
|
588
|
+
" fi",
|
|
589
|
+
' serve_out="$(mktemp)"',
|
|
590
|
+
' serve_err="$(mktemp)"',
|
|
591
|
+
` OPENCODE_CONFIG_CONTENT='{"compaction":{"tail_turns":0}}' "$oc_bin" serve --port 0 >"$serve_out" 2>"$serve_err" &`,
|
|
592
|
+
" serve_pid=$!",
|
|
593
|
+
' serve_port=""',
|
|
594
|
+
" tries=0",
|
|
595
|
+
' while [ "$tries" -lt 100 ]; do',
|
|
596
|
+
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)"`,
|
|
597
|
+
' if [ -n "$serve_port" ]; then break; fi',
|
|
598
|
+
' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
|
|
599
|
+
" sleep 0.1",
|
|
600
|
+
" tries=$((tries + 1))",
|
|
601
|
+
" done",
|
|
602
|
+
' if [ -z "$serve_port" ]; then',
|
|
603
|
+
' echo "opencode-jobs: compaction server failed to start"',
|
|
604
|
+
' sed -n "1,10p" "$serve_err" >&2',
|
|
605
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
606
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
607
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
608
|
+
" return 0",
|
|
609
|
+
" fi",
|
|
610
|
+
" healthy=0",
|
|
611
|
+
" tries=0",
|
|
612
|
+
' while [ "$tries" -lt 50 ]; do',
|
|
613
|
+
' if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$serve_port/global/health"; then',
|
|
614
|
+
" healthy=1",
|
|
615
|
+
" break",
|
|
616
|
+
" fi",
|
|
617
|
+
' if ! kill -0 "$serve_pid" 2>/dev/null; then break; fi',
|
|
618
|
+
" sleep 0.2",
|
|
619
|
+
" tries=$((tries + 1))",
|
|
620
|
+
" done",
|
|
621
|
+
' if [ "$healthy" -ne 1 ]; then',
|
|
622
|
+
' echo "opencode-jobs: compaction server never became healthy"',
|
|
623
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
624
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
625
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
626
|
+
" return 0",
|
|
627
|
+
" fi",
|
|
628
|
+
' cs_provider=""',
|
|
629
|
+
' cs_model=""',
|
|
630
|
+
' case "$oc_model" in',
|
|
631
|
+
" */*)",
|
|
632
|
+
' cs_provider="${oc_model%%/*}"',
|
|
633
|
+
' cs_model="${oc_model#*/}"',
|
|
634
|
+
" ;;",
|
|
635
|
+
" esac",
|
|
636
|
+
' if [ -z "$cs_provider" ]; then',
|
|
637
|
+
' defaults="$(curl -s --max-time 10 "http://127.0.0.1:$serve_port/config/providers")"',
|
|
638
|
+
String.raw` cs_provider="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_PROVIDER_SED}' | head -n 1)"`,
|
|
639
|
+
String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
|
|
640
|
+
" fi",
|
|
641
|
+
' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
|
|
642
|
+
' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
|
|
643
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
644
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
645
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
646
|
+
" return 0",
|
|
647
|
+
" fi",
|
|
648
|
+
' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
|
|
649
|
+
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")"`,
|
|
650
|
+
' if [ "$result" != "true" ]; then',
|
|
651
|
+
' echo "opencode-jobs: compaction failed: $result"',
|
|
652
|
+
" fi",
|
|
653
|
+
' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
|
|
654
|
+
String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
|
|
655
|
+
` 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")"`,
|
|
656
|
+
' if [ "$http" != "200" ]; then',
|
|
657
|
+
' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
|
|
658
|
+
" fi",
|
|
659
|
+
" fi",
|
|
660
|
+
' kill "$serve_pid" 2>/dev/null',
|
|
661
|
+
' wait "$serve_pid" 2>/dev/null',
|
|
662
|
+
' rm -f "$serve_out" "$serve_err"',
|
|
663
|
+
"}"
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
function runScriptContent(job, scopeId, opencodeBin) {
|
|
667
|
+
const mode = job.session ?? "new";
|
|
668
|
+
const isTracked = mode !== "new";
|
|
669
|
+
const isCompact = mode === "compact" || mode === "compact+last";
|
|
670
|
+
const isKeepLast = mode === "compact+last";
|
|
671
|
+
return [
|
|
672
|
+
"#!/bin/sh",
|
|
673
|
+
"set -u",
|
|
674
|
+
`slug=${shQuote(job.slug)}`,
|
|
675
|
+
`scope=${shQuote(scopeId)}`,
|
|
676
|
+
`oc_bin=${shQuote(opencodeBin)}`,
|
|
677
|
+
'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
|
|
678
|
+
'runs="$config_root/opencode/jobs/runs/$scope"',
|
|
679
|
+
'mkdir -p "$runs"',
|
|
680
|
+
'record_file="$runs/$slug.jsonl"',
|
|
681
|
+
...isTracked ? [
|
|
682
|
+
'sessions="$config_root/opencode/jobs/sessions/$scope"',
|
|
683
|
+
'mkdir -p "$sessions"',
|
|
684
|
+
'state_file="$sessions/$slug.txt"',
|
|
685
|
+
`session_mode=${shQuote(mode)}`,
|
|
686
|
+
'prev_session=""',
|
|
687
|
+
'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
|
|
688
|
+
] : [],
|
|
689
|
+
'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
|
|
690
|
+
'run_id="$(date +%s%N)-$$"',
|
|
691
|
+
"started=$(date +%s)",
|
|
692
|
+
'new_session=""',
|
|
693
|
+
'wt_branch=""',
|
|
694
|
+
'wt_commit=""',
|
|
695
|
+
`export OPENCODE_PERMISSION='{"question":"deny"}'`,
|
|
696
|
+
'export OPENCODE_JOBS_RUN_ID="$run_id"',
|
|
697
|
+
"finish() {",
|
|
698
|
+
' status="$1"',
|
|
699
|
+
' code="$2"',
|
|
700
|
+
" ended=$(date +%s)",
|
|
701
|
+
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"`,
|
|
702
|
+
"}",
|
|
703
|
+
"trap 'finish timeout 124; exit 124' TERM INT",
|
|
704
|
+
...job.guard === undefined ? [] : guardScriptLines(job.guard),
|
|
705
|
+
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"`,
|
|
706
|
+
...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
|
|
707
|
+
`oc_agent=${shQuote(job.run.agent ?? "")}`,
|
|
708
|
+
`oc_model=${shQuote(job.run.model ?? "")}`,
|
|
709
|
+
"prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
|
|
710
|
+
`oc_command=${shQuote("command" in job.run ? job.run.command : "")}`,
|
|
711
|
+
`oc_args=${shQuote("command" in job.run ? job.run.arguments ?? "" : "")}`,
|
|
712
|
+
`oc_prompt=${shQuote("prompt" in job.run ? job.run.prompt : "")}`,
|
|
713
|
+
`oc_keep_last=${String(isKeepLast ? 1 : 0)}`,
|
|
714
|
+
"run_opencode() {",
|
|
715
|
+
' sess="$1"',
|
|
716
|
+
' use_json="$2"',
|
|
717
|
+
" set -- run",
|
|
718
|
+
' if [ -n "$oc_agent" ]; then set -- "$@" --agent "$oc_agent"; fi',
|
|
719
|
+
' if [ -n "$oc_model" ]; then set -- "$@" --model "$oc_model"; fi',
|
|
720
|
+
' if [ -n "$sess" ]; then set -- "$@" --session "$sess"; fi',
|
|
721
|
+
' if [ "$use_json" -eq 1 ]; then set -- "$@" --format json; fi',
|
|
722
|
+
' if [ "$oc_command_mode" -eq 1 ]; then',
|
|
723
|
+
' set -- "$@" --command "$oc_command" -- "$oc_args"',
|
|
724
|
+
" else",
|
|
725
|
+
' set -- "$@" -- "$oc_prompt"',
|
|
726
|
+
" fi",
|
|
727
|
+
' "$oc_bin" "$@"',
|
|
728
|
+
"}",
|
|
729
|
+
...isCompact ? compactSessionLines() : [],
|
|
730
|
+
...isTracked ? [
|
|
731
|
+
'json_out="$(mktemp)"',
|
|
732
|
+
'run_opencode "$prev_session" 1 >"$json_out" 2>&1',
|
|
733
|
+
"code=$?",
|
|
734
|
+
'cat "$json_out"',
|
|
735
|
+
...extractSessionIdLines("new_session"),
|
|
736
|
+
'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
|
|
737
|
+
' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
|
|
738
|
+
' rm -f "$json_out"',
|
|
739
|
+
' json_out="$(mktemp)"',
|
|
740
|
+
' run_opencode "" 1 >"$json_out" 2>&1',
|
|
741
|
+
" code=$?",
|
|
742
|
+
' cat "$json_out"',
|
|
743
|
+
...extractSessionIdLines("new_session", " "),
|
|
744
|
+
"fi",
|
|
745
|
+
'cs_text=""',
|
|
746
|
+
'if [ "$oc_keep_last" -eq 1 ]; then',
|
|
747
|
+
` cs_text="$(awk '`,
|
|
748
|
+
' /"type":"text"/ {',
|
|
749
|
+
" line = $0",
|
|
750
|
+
String.raw` i = index(line, "\042text\042:\042")`,
|
|
751
|
+
" if (i == 0) next",
|
|
752
|
+
" i = i + 8",
|
|
753
|
+
' out = ""',
|
|
754
|
+
" len = length(line)",
|
|
755
|
+
" while (i <= len) {",
|
|
756
|
+
" c = substr(line, i, 1)",
|
|
757
|
+
String.raw` if (c == "\\") {`,
|
|
758
|
+
" out = out substr(line, i, 2)",
|
|
759
|
+
" i = i + 2",
|
|
760
|
+
" continue",
|
|
761
|
+
" }",
|
|
762
|
+
String.raw` if (c == "\042") break`,
|
|
763
|
+
" out = out c",
|
|
764
|
+
" i = i + 1",
|
|
765
|
+
" }",
|
|
766
|
+
" result = out",
|
|
767
|
+
" n = n + 1",
|
|
768
|
+
" }",
|
|
769
|
+
" END {",
|
|
770
|
+
" if (n > 0) {",
|
|
771
|
+
" if (length(result) > 16000) {",
|
|
772
|
+
" result = substr(result, 1, 16000)",
|
|
773
|
+
String.raw` if (substr(result, 16000, 1) == "\\") result = substr(result, 1, 15999)`,
|
|
774
|
+
" }",
|
|
775
|
+
" print result",
|
|
776
|
+
" }",
|
|
777
|
+
" }",
|
|
778
|
+
` ' "$json_out")"`,
|
|
779
|
+
"fi",
|
|
780
|
+
'rm -f "$json_out"',
|
|
781
|
+
'if [ -n "$new_session" ]; then',
|
|
782
|
+
String.raw` printf '%s\n' "$new_session" >"$state_file"`,
|
|
783
|
+
"fi"
|
|
784
|
+
] : ['run_opencode "" 0', "code=$?"],
|
|
785
|
+
"trap - TERM INT",
|
|
786
|
+
...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
|
|
787
|
+
'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
|
|
788
|
+
...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
|
|
789
|
+
"finish success 0",
|
|
790
|
+
"exit 0",
|
|
791
|
+
""
|
|
792
|
+
].join(`
|
|
793
|
+
`);
|
|
794
|
+
}
|
|
795
|
+
function serviceContent(job, options) {
|
|
796
|
+
const timeout = job.timeoutSeconds !== undefined && job.timeoutSeconds > 0 ? `TimeoutStartSec=${String(job.timeoutSeconds)}s` : "TimeoutStartSec=infinity";
|
|
797
|
+
const lines = [
|
|
798
|
+
"[Unit]",
|
|
799
|
+
`Description=OpenCode job: ${escapeUnitText(job.name)} (${job.slug})`,
|
|
800
|
+
"",
|
|
801
|
+
"[Service]",
|
|
802
|
+
"Type=oneshot",
|
|
803
|
+
`WorkingDirectory=${unitQuote(options.workdir)}`,
|
|
804
|
+
`Environment=${unitQuote(`PATH=${options.pathEnvironment}`)}`,
|
|
805
|
+
`ExecStart=/bin/sh ${unitQuote(options.runScript)}`,
|
|
806
|
+
timeout,
|
|
807
|
+
`StandardOutput=append:${unitQuote(options.log)}`,
|
|
808
|
+
`StandardError=append:${unitQuote(options.log)}`
|
|
809
|
+
];
|
|
810
|
+
return `${lines.join(`
|
|
811
|
+
`)}
|
|
812
|
+
`;
|
|
813
|
+
}
|
|
814
|
+
function timerContent(job, onCalendars) {
|
|
815
|
+
return [
|
|
816
|
+
"[Unit]",
|
|
817
|
+
`Description=OpenCode job timer: ${escapeUnitText(job.name)} (${job.slug})`,
|
|
818
|
+
"",
|
|
819
|
+
"[Timer]",
|
|
820
|
+
...onCalendars.map((calendar) => `OnCalendar=${calendar}`),
|
|
821
|
+
"Persistent=true",
|
|
822
|
+
"",
|
|
823
|
+
"[Install]",
|
|
824
|
+
"WantedBy=timers.target",
|
|
825
|
+
""
|
|
826
|
+
].join(`
|
|
827
|
+
`);
|
|
828
|
+
}
|
|
309
829
|
function systemctl(systemctlArguments) {
|
|
310
830
|
const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
|
|
311
831
|
encoding: "utf8"
|
|
@@ -316,24 +836,154 @@ function systemctl(systemctlArguments) {
|
|
|
316
836
|
stderr: result.stderr.trim()
|
|
317
837
|
};
|
|
318
838
|
}
|
|
839
|
+
function systemdHint(stderr) {
|
|
840
|
+
if (/failed to connect|not connected|dbus/i.test(stderr)) {
|
|
841
|
+
return `
|
|
842
|
+
Hint: no systemd user session is reachable. Over SSH try enabling lingering: loginctl enable-linger $USER`;
|
|
843
|
+
}
|
|
844
|
+
return "";
|
|
845
|
+
}
|
|
846
|
+
function timerStatus(base) {
|
|
847
|
+
const result = systemctl([
|
|
848
|
+
"show",
|
|
849
|
+
timerUnit(base),
|
|
850
|
+
"-p",
|
|
851
|
+
"NextElapseUSecRealtime",
|
|
852
|
+
"-p",
|
|
853
|
+
"LastTriggerUSec",
|
|
854
|
+
"--value"
|
|
855
|
+
]);
|
|
856
|
+
if (!result.ok)
|
|
857
|
+
return { next: undefined, last: undefined };
|
|
858
|
+
const [next = "n/a", last = "n/a"] = result.stdout.split(`
|
|
859
|
+
`, 2);
|
|
860
|
+
return {
|
|
861
|
+
next: next === "n/a" ? undefined : next,
|
|
862
|
+
last: last === "n/a" ? undefined : last
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
|
|
866
|
+
const script = runScriptPath(scopeId, job.slug);
|
|
867
|
+
const base = unitBase(scopeId, job.slug);
|
|
868
|
+
mkdirSync2(logDirectory(scopeId), { recursive: true });
|
|
869
|
+
mkdirSync2(runsDirectory(scopeId), { recursive: true });
|
|
870
|
+
atomicWriteExecutable(script, runScriptContent(job, scopeId, opencodeBin));
|
|
871
|
+
const onCalendars = cronToOnCalendar(parseCron(job.schedule));
|
|
872
|
+
atomicWrite(path4.join(systemdUserDirectory(), timerUnit(base)), timerContent(job, onCalendars));
|
|
873
|
+
atomicWrite(path4.join(systemdUserDirectory(), serviceUnit(base)), serviceContent(job, {
|
|
874
|
+
workdir: path4.resolve(workdir),
|
|
875
|
+
runScript: script,
|
|
876
|
+
log: logFile(scopeId, job.slug),
|
|
877
|
+
pathEnvironment
|
|
878
|
+
}));
|
|
879
|
+
return base;
|
|
880
|
+
}
|
|
319
881
|
function removeJobUnits(scopeId, slug) {
|
|
320
882
|
const base = unitBase(scopeId, slug);
|
|
321
883
|
systemctl(["disable", "--now", timerUnit(base)]);
|
|
322
884
|
for (const file of [
|
|
323
|
-
|
|
324
|
-
|
|
885
|
+
path4.join(systemdUserDirectory(), timerUnit(base)),
|
|
886
|
+
path4.join(systemdUserDirectory(), serviceUnit(base))
|
|
325
887
|
]) {
|
|
326
|
-
if (
|
|
888
|
+
if (existsSync2(file))
|
|
327
889
|
rmSync(file);
|
|
328
890
|
}
|
|
329
891
|
const script = runScriptPath(scopeId, slug);
|
|
330
|
-
if (
|
|
892
|
+
if (existsSync2(script))
|
|
331
893
|
rmSync(script);
|
|
332
894
|
}
|
|
895
|
+
function removeStaleUnits(scopeId, expectedSlugs) {
|
|
896
|
+
const prefix = `opencode-sched-${scopeId}-`;
|
|
897
|
+
const removed = [];
|
|
898
|
+
if (existsSync2(systemdUserDirectory())) {
|
|
899
|
+
for (const entry of readdirSync2(systemdUserDirectory())) {
|
|
900
|
+
if (!entry.startsWith(prefix))
|
|
901
|
+
continue;
|
|
902
|
+
if (!entry.endsWith(".service") && !entry.endsWith(".timer"))
|
|
903
|
+
continue;
|
|
904
|
+
const slug = entry.slice(prefix.length).replaceAll(/\.(service|timer)$/g, "");
|
|
905
|
+
if (!expectedSlugs.has(slug)) {
|
|
906
|
+
rmSync(path4.join(systemdUserDirectory(), entry));
|
|
907
|
+
removed.push(slug);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (existsSync2(scopeDirectory(scopeId))) {
|
|
912
|
+
const entries = readdirSync2(scopeDirectory(scopeId));
|
|
913
|
+
for (const entry of entries) {
|
|
914
|
+
const match = /^run-(.+)\.sh$/.exec(entry);
|
|
915
|
+
if (match === null)
|
|
916
|
+
continue;
|
|
917
|
+
const [, slug = ""] = match;
|
|
918
|
+
if (!expectedSlugs.has(slug))
|
|
919
|
+
rmSync(path4.join(scopeDirectory(scopeId), entry));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return removed;
|
|
923
|
+
}
|
|
333
924
|
|
|
334
925
|
// src/project.ts
|
|
926
|
+
function enableProject(workdir) {
|
|
927
|
+
if (process.platform !== "linux")
|
|
928
|
+
throw new Error("Scheduled jobs are only supported on Linux (systemd user units)");
|
|
929
|
+
const { jobs, errors } = loadJobs(workdir);
|
|
930
|
+
if (errors.length > 0)
|
|
931
|
+
throw new Error(`Invalid job definitions:
|
|
932
|
+
${errors.join(`
|
|
933
|
+
`)}`);
|
|
934
|
+
if (jobs.length === 0) {
|
|
935
|
+
throw new Error(`No job definitions found in ${jobsDirectory(workdir)}. Create one with schedule_job first.`);
|
|
936
|
+
}
|
|
937
|
+
const abs = path5.resolve(workdir);
|
|
938
|
+
const scopeId = deriveScopeId(abs);
|
|
939
|
+
const opencodeBin = findOpencode();
|
|
940
|
+
const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
941
|
+
const bases = jobs.map((job) => writeJobUnits(job, abs, scopeId, opencodeBin, pathEnvironment));
|
|
942
|
+
const removed = removeStaleUnits(scopeId, new Set(jobs.map((job) => job.slug)));
|
|
943
|
+
const reload = systemctl(["daemon-reload"]);
|
|
944
|
+
if (!reload.ok)
|
|
945
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
946
|
+
const failures = [];
|
|
947
|
+
for (const base of bases) {
|
|
948
|
+
const enable = systemctl(["enable", "--now", timerUnit(base)]);
|
|
949
|
+
if (!enable.ok)
|
|
950
|
+
failures.push(`${timerUnit(base)}: ${enable.stderr}${systemdHint(enable.stderr)}`);
|
|
951
|
+
}
|
|
952
|
+
const registry = loadRegistry();
|
|
953
|
+
const previous = registry.projects[abs];
|
|
954
|
+
registry.projects[abs] = {
|
|
955
|
+
scopeId,
|
|
956
|
+
workdir: abs,
|
|
957
|
+
enabledAt: previous?.enabledAt ?? nowIso(),
|
|
958
|
+
updatedAt: nowIso(),
|
|
959
|
+
jobs: jobs.map((job) => job.slug)
|
|
960
|
+
};
|
|
961
|
+
saveRegistry(registry);
|
|
962
|
+
const lines = [
|
|
963
|
+
`Enabled ${String(jobs.length)} job(s) for ${abs} (scope ${scopeId})`
|
|
964
|
+
];
|
|
965
|
+
if (removed.length > 0)
|
|
966
|
+
lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
|
|
967
|
+
lines.push(...describeJobSchedules(jobs, scopeId));
|
|
968
|
+
if (failures.length > 0)
|
|
969
|
+
lines.push(`Timer activation failures:
|
|
970
|
+
${failures.join(`
|
|
971
|
+
`)}`);
|
|
972
|
+
return lines.join(`
|
|
973
|
+
`);
|
|
974
|
+
}
|
|
975
|
+
function describeJobSchedules(jobs, scopeId) {
|
|
976
|
+
const lines = [];
|
|
977
|
+
for (const job of jobs) {
|
|
978
|
+
const sets = parseCron(job.schedule);
|
|
979
|
+
const next = timerStatus(unitBase(scopeId, job.slug)).next;
|
|
980
|
+
const nextDesc = next === undefined ? "" : `, next: ${next}`;
|
|
981
|
+
lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}`);
|
|
982
|
+
}
|
|
983
|
+
return lines;
|
|
984
|
+
}
|
|
335
985
|
function disableProject(workdir) {
|
|
336
|
-
const abs =
|
|
986
|
+
const abs = path5.resolve(workdir);
|
|
337
987
|
const entry = registryEntry(abs);
|
|
338
988
|
if (entry === undefined)
|
|
339
989
|
return `Project is not enabled: ${abs}`;
|
|
@@ -352,6 +1002,82 @@ function disableProject(workdir) {
|
|
|
352
1002
|
`);
|
|
353
1003
|
}
|
|
354
1004
|
|
|
1005
|
+
// src/migration.ts
|
|
1006
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, renameSync as renameSync2, rmdirSync } from "node:fs";
|
|
1007
|
+
import path6 from "node:path";
|
|
1008
|
+
function legacyJobsStateDirectory() {
|
|
1009
|
+
return path6.join(configRoot(), "opencode", "scheduler");
|
|
1010
|
+
}
|
|
1011
|
+
function legacyRegistryPath() {
|
|
1012
|
+
return path6.join(legacyJobsStateDirectory(), "registry.json");
|
|
1013
|
+
}
|
|
1014
|
+
function legacyDefinitionsDirectory(workdir) {
|
|
1015
|
+
return path6.join(workdir, ".opencode", "scheduler", "jobs");
|
|
1016
|
+
}
|
|
1017
|
+
function migrationMoves(projects) {
|
|
1018
|
+
return [
|
|
1019
|
+
{
|
|
1020
|
+
from: legacyJobsStateDirectory(),
|
|
1021
|
+
to: jobsStateDirectory()
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
from: path6.join(configRoot(), "opencode", "logs", "scheduler"),
|
|
1025
|
+
to: path6.join(configRoot(), "opencode", "logs", "jobs")
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
from: path6.join(stateRoot(), "opencode", "scheduler", "worktrees"),
|
|
1029
|
+
to: path6.join(stateRoot(), "opencode", "jobs", "worktrees")
|
|
1030
|
+
},
|
|
1031
|
+
...[...projects].map((workdir) => ({
|
|
1032
|
+
from: legacyDefinitionsDirectory(workdir),
|
|
1033
|
+
to: jobsDirectory(workdir)
|
|
1034
|
+
}))
|
|
1035
|
+
];
|
|
1036
|
+
}
|
|
1037
|
+
function removeLegacyProjectDirectory(workdir) {
|
|
1038
|
+
try {
|
|
1039
|
+
rmdirSync(path6.join(workdir, ".opencode", "scheduler"));
|
|
1040
|
+
} catch {}
|
|
1041
|
+
}
|
|
1042
|
+
function migrateStorage(projectDirectory, shouldResync) {
|
|
1043
|
+
const project = path6.resolve(projectDirectory);
|
|
1044
|
+
const legacyRegistry = legacyRegistryPath();
|
|
1045
|
+
const canonicalRegistry = path6.join(jobsStateDirectory(), "registry.json");
|
|
1046
|
+
const registryFile = existsSync3(legacyRegistry) ? legacyRegistry : canonicalRegistry;
|
|
1047
|
+
const registry = existsSync3(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
|
|
1048
|
+
const registeredProjects = new Set(Object.keys(registry.projects));
|
|
1049
|
+
const projects = new Set([project, ...registeredProjects]);
|
|
1050
|
+
const moves = migrationMoves(projects).filter(({ from }) => existsSync3(from));
|
|
1051
|
+
for (const { from, to } of moves) {
|
|
1052
|
+
if (existsSync3(to)) {
|
|
1053
|
+
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.`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
for (const { from, to } of moves) {
|
|
1057
|
+
mkdirSync3(path6.dirname(to), { recursive: true });
|
|
1058
|
+
renameSync2(from, to);
|
|
1059
|
+
}
|
|
1060
|
+
for (const workdir of projects)
|
|
1061
|
+
removeLegacyProjectDirectory(workdir);
|
|
1062
|
+
const result = {
|
|
1063
|
+
moved: moves,
|
|
1064
|
+
resyncedProjects: [],
|
|
1065
|
+
warnings: []
|
|
1066
|
+
};
|
|
1067
|
+
if (!shouldResync || moves.length === 0)
|
|
1068
|
+
return result;
|
|
1069
|
+
for (const workdir of registeredProjects) {
|
|
1070
|
+
try {
|
|
1071
|
+
enableProject(workdir);
|
|
1072
|
+
result.resyncedProjects.push(workdir);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1075
|
+
result.warnings.push(`${workdir}: ${message}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return result;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
355
1081
|
// src/install.ts
|
|
356
1082
|
var PACKAGE_NAME = "opencode-jobs";
|
|
357
1083
|
var SKILL_NAME = "opencode-jobs";
|
|
@@ -362,20 +1088,20 @@ var configSchema = z3.looseObject({
|
|
|
362
1088
|
var CONFIG_LOCATIONS = [
|
|
363
1089
|
"opencode.json",
|
|
364
1090
|
"opencode.jsonc",
|
|
365
|
-
|
|
366
|
-
|
|
1091
|
+
path7.join(".opencode", "opencode.json"),
|
|
1092
|
+
path7.join(".opencode", "opencode.jsonc")
|
|
367
1093
|
];
|
|
368
1094
|
function existingConfigPath(projectDirectory) {
|
|
369
1095
|
for (const relativePath of CONFIG_LOCATIONS) {
|
|
370
|
-
const configPath =
|
|
371
|
-
if (
|
|
1096
|
+
const configPath = path7.join(projectDirectory, relativePath);
|
|
1097
|
+
if (existsSync4(configPath))
|
|
372
1098
|
return configPath;
|
|
373
1099
|
}
|
|
374
1100
|
return;
|
|
375
1101
|
}
|
|
376
1102
|
function readConfig(configPath) {
|
|
377
1103
|
try {
|
|
378
|
-
const value = JSON.parse(
|
|
1104
|
+
const value = JSON.parse(readFileSync3(configPath, "utf8"));
|
|
379
1105
|
const result = configSchema.safeParse(value);
|
|
380
1106
|
return result.success ? result.data : undefined;
|
|
381
1107
|
} catch {
|
|
@@ -392,7 +1118,7 @@ function isPackageReference(entry) {
|
|
|
392
1118
|
}
|
|
393
1119
|
function installPluginConfig(projectDirectory) {
|
|
394
1120
|
const existingPath = existingConfigPath(projectDirectory);
|
|
395
|
-
const configPath = existingPath ??
|
|
1121
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
396
1122
|
if (existingPath === undefined) {
|
|
397
1123
|
atomicWrite(configPath, `${JSON.stringify({
|
|
398
1124
|
$schema: CONFIG_SCHEMA,
|
|
@@ -414,34 +1140,36 @@ function installPluginConfig(projectDirectory) {
|
|
|
414
1140
|
return { status: "added", configPath };
|
|
415
1141
|
}
|
|
416
1142
|
function installSkill(projectDirectory, packageDirectory) {
|
|
417
|
-
const sourcePath =
|
|
418
|
-
const skillPath =
|
|
419
|
-
const content =
|
|
420
|
-
if (
|
|
1143
|
+
const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
|
|
1144
|
+
const skillPath = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME, "SKILL.md");
|
|
1145
|
+
const content = readFileSync3(sourcePath, "utf8");
|
|
1146
|
+
if (existsSync4(skillPath) && readFileSync3(skillPath, "utf8") === content) {
|
|
421
1147
|
return { status: "unchanged", skillPath };
|
|
422
1148
|
}
|
|
423
1149
|
atomicWrite(skillPath, content);
|
|
424
1150
|
return { status: "written", skillPath };
|
|
425
1151
|
}
|
|
426
1152
|
function installProject(projectDirectory, packageDirectory) {
|
|
427
|
-
const resolvedProject =
|
|
428
|
-
if (!
|
|
1153
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1154
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
429
1155
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
430
1156
|
}
|
|
1157
|
+
const migration = migrateStorage(resolvedProject, true);
|
|
431
1158
|
return {
|
|
432
1159
|
projectDirectory: resolvedProject,
|
|
433
1160
|
plugin: installPluginConfig(resolvedProject),
|
|
434
|
-
skill: installSkill(resolvedProject, packageDirectory)
|
|
1161
|
+
skill: installSkill(resolvedProject, packageDirectory),
|
|
1162
|
+
...migration.moved.length > 0 && { migration }
|
|
435
1163
|
};
|
|
436
1164
|
}
|
|
437
1165
|
function removeDirectoryIfEmpty(directory) {
|
|
438
1166
|
try {
|
|
439
|
-
|
|
1167
|
+
rmdirSync2(directory);
|
|
440
1168
|
} catch {}
|
|
441
1169
|
}
|
|
442
1170
|
function uninstallPluginConfig(projectDirectory) {
|
|
443
1171
|
const existingPath = existingConfigPath(projectDirectory);
|
|
444
|
-
const configPath = existingPath ??
|
|
1172
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
445
1173
|
if (existingPath === undefined)
|
|
446
1174
|
return { status: "absent", configPath };
|
|
447
1175
|
const config = readConfig(configPath);
|
|
@@ -465,21 +1193,21 @@ function uninstallPluginConfig(projectDirectory) {
|
|
|
465
1193
|
return { status: "removed", configPath };
|
|
466
1194
|
}
|
|
467
1195
|
function uninstallSkill(projectDirectory, packageDirectory) {
|
|
468
|
-
const sourcePath =
|
|
469
|
-
const skillDirectory =
|
|
470
|
-
const skillPath =
|
|
471
|
-
if (!
|
|
1196
|
+
const sourcePath = path7.join(packageDirectory, "skill", SKILL_NAME, "SKILL.md");
|
|
1197
|
+
const skillDirectory = path7.join(projectDirectory, ".opencode", "skills", SKILL_NAME);
|
|
1198
|
+
const skillPath = path7.join(skillDirectory, "SKILL.md");
|
|
1199
|
+
if (!existsSync4(skillPath))
|
|
472
1200
|
return { status: "absent", skillPath };
|
|
473
|
-
if (
|
|
1201
|
+
if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
|
|
474
1202
|
return { status: "kept-modified", skillPath };
|
|
475
1203
|
}
|
|
476
1204
|
rmSync2(skillDirectory, { recursive: true, force: true });
|
|
477
|
-
removeDirectoryIfEmpty(
|
|
478
|
-
removeDirectoryIfEmpty(
|
|
1205
|
+
removeDirectoryIfEmpty(path7.dirname(skillDirectory));
|
|
1206
|
+
removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
|
|
479
1207
|
return { status: "removed", skillPath };
|
|
480
1208
|
}
|
|
481
1209
|
function purgeProjectData(projectDirectory) {
|
|
482
|
-
const abs =
|
|
1210
|
+
const abs = path7.resolve(projectDirectory);
|
|
483
1211
|
const scopeId = deriveScopeId(abs);
|
|
484
1212
|
const targets = [
|
|
485
1213
|
scopeDirectory(scopeId),
|
|
@@ -488,11 +1216,11 @@ function purgeProjectData(projectDirectory) {
|
|
|
488
1216
|
logDirectory(scopeId),
|
|
489
1217
|
locksDirectory(scopeId),
|
|
490
1218
|
worktreesDirectory(scopeId),
|
|
491
|
-
|
|
1219
|
+
path7.join(abs, ".opencode", "jobs")
|
|
492
1220
|
];
|
|
493
1221
|
const paths = [];
|
|
494
1222
|
for (const target of targets) {
|
|
495
|
-
if (!
|
|
1223
|
+
if (!existsSync4(target))
|
|
496
1224
|
continue;
|
|
497
1225
|
rmSync2(target, { recursive: true, force: true });
|
|
498
1226
|
paths.push(target);
|
|
@@ -500,14 +1228,15 @@ function purgeProjectData(projectDirectory) {
|
|
|
500
1228
|
if (paths.includes(worktreesDirectory(scopeId))) {
|
|
501
1229
|
spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
|
|
502
1230
|
}
|
|
503
|
-
removeDirectoryIfEmpty(
|
|
1231
|
+
removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
|
|
504
1232
|
return { paths };
|
|
505
1233
|
}
|
|
506
1234
|
function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
507
|
-
const resolvedProject =
|
|
508
|
-
if (!
|
|
1235
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1236
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
509
1237
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
510
1238
|
}
|
|
1239
|
+
const migration = migrateStorage(resolvedProject, false);
|
|
511
1240
|
const wasEnabled = registryEntry(resolvedProject) !== undefined;
|
|
512
1241
|
if (wasEnabled)
|
|
513
1242
|
disableProject(resolvedProject);
|
|
@@ -515,7 +1244,8 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
|
515
1244
|
projectDirectory: resolvedProject,
|
|
516
1245
|
disabled: wasEnabled,
|
|
517
1246
|
plugin: uninstallPluginConfig(resolvedProject),
|
|
518
|
-
skill: uninstallSkill(resolvedProject, packageDirectory)
|
|
1247
|
+
skill: uninstallSkill(resolvedProject, packageDirectory),
|
|
1248
|
+
...migration.moved.length > 0 && { migration }
|
|
519
1249
|
};
|
|
520
1250
|
if (shouldPurge)
|
|
521
1251
|
uninstall.purge = purgeProjectData(resolvedProject);
|
|
@@ -528,10 +1258,10 @@ var USAGE = `Usage: opencode-jobs <command> [projectDir]
|
|
|
528
1258
|
Commands:
|
|
529
1259
|
install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
|
|
530
1260
|
uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
|
|
531
|
-
--purge also deletes job definitions and
|
|
1261
|
+
--purge also deletes job definitions and job data
|
|
532
1262
|
help Show this help`;
|
|
533
1263
|
function packageDirectory() {
|
|
534
|
-
return
|
|
1264
|
+
return path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..");
|
|
535
1265
|
}
|
|
536
1266
|
function printError(message) {
|
|
537
1267
|
console.error(`Error: ${message}
|