opencode-jobs 0.1.2 → 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 +87 -26
- package/dist/cli.js +827 -63
- package/dist/index.d.ts +1 -1
- package/dist/index.js +302 -49
- package/dist/install.d.ts +3 -0
- package/dist/internals.d.ts +2 -1
- package/dist/job.d.ts +7 -0
- package/dist/migration.d.ts +9 -0
- package/dist/paths.d.ts +4 -1
- package/dist/registry.d.ts +1 -0
- package/dist/runs.d.ts +2 -0
- package/dist/tools.d.ts +1 -1
- package/package.json +1 -1
- package/skill/opencode-jobs/SKILL.md +10 -3
package/dist/cli.js
CHANGED
|
@@ -1,12 +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
|
|
9
|
-
import
|
|
8
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, rmSync as rmSync2, rmdirSync as rmdirSync2, statSync } from "node:fs";
|
|
9
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
10
|
+
import path7 from "node:path";
|
|
10
11
|
import { z as z3 } from "zod";
|
|
11
12
|
|
|
12
13
|
// src/paths.ts
|
|
@@ -17,27 +18,42 @@ import { homedir } from "node:os";
|
|
|
17
18
|
function configRoot() {
|
|
18
19
|
return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
19
20
|
}
|
|
20
|
-
function
|
|
21
|
-
return path.join(
|
|
21
|
+
function stateRoot() {
|
|
22
|
+
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
23
|
+
}
|
|
24
|
+
function worktreesDirectory(scopeId) {
|
|
25
|
+
return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
|
|
26
|
+
}
|
|
27
|
+
function locksDirectory(scopeId) {
|
|
28
|
+
return path.join(jobsStateDirectory(), "locks", scopeId);
|
|
29
|
+
}
|
|
30
|
+
function jobsStateDirectory() {
|
|
31
|
+
return path.join(configRoot(), "opencode", "jobs");
|
|
22
32
|
}
|
|
23
33
|
function registryPath() {
|
|
24
|
-
return path.join(
|
|
34
|
+
return path.join(jobsStateDirectory(), "registry.json");
|
|
25
35
|
}
|
|
26
36
|
function scopeDirectory(scopeId) {
|
|
27
|
-
return path.join(
|
|
37
|
+
return path.join(jobsStateDirectory(), "scopes", scopeId);
|
|
28
38
|
}
|
|
29
39
|
function runsDirectory(scopeId) {
|
|
30
|
-
return path.join(
|
|
40
|
+
return path.join(jobsStateDirectory(), "runs", scopeId);
|
|
31
41
|
}
|
|
32
42
|
function sessionStateDirectory(scopeId) {
|
|
33
|
-
return path.join(
|
|
43
|
+
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
34
44
|
}
|
|
35
45
|
function logDirectory(scopeId) {
|
|
36
|
-
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`);
|
|
37
50
|
}
|
|
38
51
|
function systemdUserDirectory() {
|
|
39
52
|
return path.join(configRoot(), "systemd", "user");
|
|
40
53
|
}
|
|
54
|
+
function jobsDirectory(workdir) {
|
|
55
|
+
return path.join(workdir, ".opencode", "jobs");
|
|
56
|
+
}
|
|
41
57
|
function unitBase(scopeId, slug) {
|
|
42
58
|
return `opencode-sched-${scopeId}-${slug}`;
|
|
43
59
|
}
|
|
@@ -54,12 +70,30 @@ function slugify(input) {
|
|
|
54
70
|
const slug = input.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-+|-+$/g, "").slice(0, 64);
|
|
55
71
|
return slug.length > 0 ? slug : "job";
|
|
56
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
|
+
}
|
|
57
84
|
function atomicWrite(file, content) {
|
|
58
85
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
59
86
|
const temporary = `${file}.tmp`;
|
|
60
87
|
writeFileSync(temporary, content);
|
|
61
88
|
renameSync(temporary, file);
|
|
62
89
|
}
|
|
90
|
+
function atomicWriteExecutable(file, content) {
|
|
91
|
+
atomicWrite(file, content);
|
|
92
|
+
chmodSync(file, 493);
|
|
93
|
+
}
|
|
94
|
+
function nowIso() {
|
|
95
|
+
return new Date().toISOString();
|
|
96
|
+
}
|
|
63
97
|
function deriveScopeId(workdir) {
|
|
64
98
|
const abs = path.resolve(workdir);
|
|
65
99
|
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 12);
|
|
@@ -67,12 +101,15 @@ function deriveScopeId(workdir) {
|
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
// src/project.ts
|
|
70
|
-
import
|
|
104
|
+
import path5 from "node:path";
|
|
71
105
|
|
|
72
106
|
// src/job.ts
|
|
107
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
108
|
+
import path2 from "node:path";
|
|
73
109
|
import { z } from "zod";
|
|
74
110
|
|
|
75
111
|
// src/cron.ts
|
|
112
|
+
var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
76
113
|
var DOW_NAMES = {
|
|
77
114
|
sun: 0,
|
|
78
115
|
mon: 1,
|
|
@@ -157,6 +194,42 @@ function parseCron(expression) {
|
|
|
157
194
|
dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
|
|
158
195
|
};
|
|
159
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
|
+
}
|
|
160
233
|
|
|
161
234
|
// src/json.ts
|
|
162
235
|
function errorMessage(error) {
|
|
@@ -210,6 +283,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
|
|
|
210
283
|
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
211
284
|
});
|
|
212
285
|
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
286
|
+
var worktreeSchema = z.union([
|
|
287
|
+
z.literal(true),
|
|
288
|
+
z.strictObject({
|
|
289
|
+
base: nonEmptyStringSchema.optional(),
|
|
290
|
+
ref: nonEmptyStringSchema.optional(),
|
|
291
|
+
commitMessage: nonEmptyStringSchema.optional()
|
|
292
|
+
})
|
|
293
|
+
]).transform((value) => {
|
|
294
|
+
if (value === true)
|
|
295
|
+
return {};
|
|
296
|
+
return {
|
|
297
|
+
...value.base !== undefined && { base: value.base },
|
|
298
|
+
...value.ref !== undefined && { ref: value.ref },
|
|
299
|
+
...value.commitMessage !== undefined && {
|
|
300
|
+
commitMessage: value.commitMessage
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
});
|
|
213
304
|
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
214
305
|
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
215
306
|
try {
|
|
@@ -225,14 +316,87 @@ var jobFileSchema = z.strictObject({
|
|
|
225
316
|
run: runSpecSchema,
|
|
226
317
|
session: sessionSchema.default("new"),
|
|
227
318
|
guard: guardSchema.optional(),
|
|
319
|
+
worktree: worktreeSchema.optional(),
|
|
228
320
|
timeoutSeconds: timeoutSchema.optional(),
|
|
229
321
|
createdAt: z.string().optional(),
|
|
230
322
|
updatedAt: z.string().optional()
|
|
231
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
|
+
}
|
|
232
396
|
|
|
233
397
|
// src/registry.ts
|
|
234
|
-
import { readFileSync } from "node:fs";
|
|
235
|
-
import
|
|
398
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
399
|
+
import path3 from "node:path";
|
|
236
400
|
import { z as z2 } from "zod";
|
|
237
401
|
var registryEntrySchema = z2.looseObject({
|
|
238
402
|
scopeId: z2.string(),
|
|
@@ -245,19 +409,22 @@ var registryFileSchema = z2.object({
|
|
|
245
409
|
version: z2.literal(1),
|
|
246
410
|
projects: z2.record(z2.string(), z2.unknown())
|
|
247
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
|
+
}
|
|
248
425
|
function loadRegistry() {
|
|
249
426
|
try {
|
|
250
|
-
|
|
251
|
-
const result = registryFileSchema.safeParse(parsed);
|
|
252
|
-
if (!result.success)
|
|
253
|
-
return { version: 1, projects: {} };
|
|
254
|
-
const projects = {};
|
|
255
|
-
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
256
|
-
const entry = registryEntrySchema.safeParse(value);
|
|
257
|
-
if (entry.success)
|
|
258
|
-
projects[key] = entry.data;
|
|
259
|
-
}
|
|
260
|
-
return { version: 1, projects };
|
|
427
|
+
return readRegistryFile(registryPath());
|
|
261
428
|
} catch {
|
|
262
429
|
return { version: 1, projects: {} };
|
|
263
430
|
}
|
|
@@ -267,16 +434,398 @@ function saveRegistry(registry) {
|
|
|
267
434
|
`);
|
|
268
435
|
}
|
|
269
436
|
function registryEntry(workdir) {
|
|
270
|
-
return loadRegistry().projects[
|
|
437
|
+
return loadRegistry().projects[path3.resolve(workdir)];
|
|
271
438
|
}
|
|
272
439
|
|
|
273
440
|
// src/systemd.ts
|
|
274
|
-
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";
|
|
275
442
|
import { spawnSync } from "node:child_process";
|
|
276
|
-
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
|
+
}
|
|
277
573
|
var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
|
|
278
574
|
var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
279
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
|
+
}
|
|
280
829
|
function systemctl(systemctlArguments) {
|
|
281
830
|
const result = spawnSync("systemctl", ["--user", ...systemctlArguments], {
|
|
282
831
|
encoding: "utf8"
|
|
@@ -287,24 +836,154 @@ function systemctl(systemctlArguments) {
|
|
|
287
836
|
stderr: result.stderr.trim()
|
|
288
837
|
};
|
|
289
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
|
+
}
|
|
290
881
|
function removeJobUnits(scopeId, slug) {
|
|
291
882
|
const base = unitBase(scopeId, slug);
|
|
292
883
|
systemctl(["disable", "--now", timerUnit(base)]);
|
|
293
884
|
for (const file of [
|
|
294
|
-
|
|
295
|
-
|
|
885
|
+
path4.join(systemdUserDirectory(), timerUnit(base)),
|
|
886
|
+
path4.join(systemdUserDirectory(), serviceUnit(base))
|
|
296
887
|
]) {
|
|
297
|
-
if (
|
|
888
|
+
if (existsSync2(file))
|
|
298
889
|
rmSync(file);
|
|
299
890
|
}
|
|
300
891
|
const script = runScriptPath(scopeId, slug);
|
|
301
|
-
if (
|
|
892
|
+
if (existsSync2(script))
|
|
302
893
|
rmSync(script);
|
|
303
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
|
+
}
|
|
304
924
|
|
|
305
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
|
+
}
|
|
306
985
|
function disableProject(workdir) {
|
|
307
|
-
const abs =
|
|
986
|
+
const abs = path5.resolve(workdir);
|
|
308
987
|
const entry = registryEntry(abs);
|
|
309
988
|
if (entry === undefined)
|
|
310
989
|
return `Project is not enabled: ${abs}`;
|
|
@@ -323,6 +1002,82 @@ function disableProject(workdir) {
|
|
|
323
1002
|
`);
|
|
324
1003
|
}
|
|
325
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
|
+
|
|
326
1081
|
// src/install.ts
|
|
327
1082
|
var PACKAGE_NAME = "opencode-jobs";
|
|
328
1083
|
var SKILL_NAME = "opencode-jobs";
|
|
@@ -333,20 +1088,20 @@ var configSchema = z3.looseObject({
|
|
|
333
1088
|
var CONFIG_LOCATIONS = [
|
|
334
1089
|
"opencode.json",
|
|
335
1090
|
"opencode.jsonc",
|
|
336
|
-
|
|
337
|
-
|
|
1091
|
+
path7.join(".opencode", "opencode.json"),
|
|
1092
|
+
path7.join(".opencode", "opencode.jsonc")
|
|
338
1093
|
];
|
|
339
1094
|
function existingConfigPath(projectDirectory) {
|
|
340
1095
|
for (const relativePath of CONFIG_LOCATIONS) {
|
|
341
|
-
const configPath =
|
|
342
|
-
if (
|
|
1096
|
+
const configPath = path7.join(projectDirectory, relativePath);
|
|
1097
|
+
if (existsSync4(configPath))
|
|
343
1098
|
return configPath;
|
|
344
1099
|
}
|
|
345
1100
|
return;
|
|
346
1101
|
}
|
|
347
1102
|
function readConfig(configPath) {
|
|
348
1103
|
try {
|
|
349
|
-
const value = JSON.parse(
|
|
1104
|
+
const value = JSON.parse(readFileSync3(configPath, "utf8"));
|
|
350
1105
|
const result = configSchema.safeParse(value);
|
|
351
1106
|
return result.success ? result.data : undefined;
|
|
352
1107
|
} catch {
|
|
@@ -363,7 +1118,7 @@ function isPackageReference(entry) {
|
|
|
363
1118
|
}
|
|
364
1119
|
function installPluginConfig(projectDirectory) {
|
|
365
1120
|
const existingPath = existingConfigPath(projectDirectory);
|
|
366
|
-
const configPath = existingPath ??
|
|
1121
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
367
1122
|
if (existingPath === undefined) {
|
|
368
1123
|
atomicWrite(configPath, `${JSON.stringify({
|
|
369
1124
|
$schema: CONFIG_SCHEMA,
|
|
@@ -385,34 +1140,36 @@ function installPluginConfig(projectDirectory) {
|
|
|
385
1140
|
return { status: "added", configPath };
|
|
386
1141
|
}
|
|
387
1142
|
function installSkill(projectDirectory, packageDirectory) {
|
|
388
|
-
const sourcePath =
|
|
389
|
-
const skillPath =
|
|
390
|
-
const content =
|
|
391
|
-
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) {
|
|
392
1147
|
return { status: "unchanged", skillPath };
|
|
393
1148
|
}
|
|
394
1149
|
atomicWrite(skillPath, content);
|
|
395
1150
|
return { status: "written", skillPath };
|
|
396
1151
|
}
|
|
397
1152
|
function installProject(projectDirectory, packageDirectory) {
|
|
398
|
-
const resolvedProject =
|
|
399
|
-
if (!
|
|
1153
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1154
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
400
1155
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
401
1156
|
}
|
|
1157
|
+
const migration = migrateStorage(resolvedProject, true);
|
|
402
1158
|
return {
|
|
403
1159
|
projectDirectory: resolvedProject,
|
|
404
1160
|
plugin: installPluginConfig(resolvedProject),
|
|
405
|
-
skill: installSkill(resolvedProject, packageDirectory)
|
|
1161
|
+
skill: installSkill(resolvedProject, packageDirectory),
|
|
1162
|
+
...migration.moved.length > 0 && { migration }
|
|
406
1163
|
};
|
|
407
1164
|
}
|
|
408
1165
|
function removeDirectoryIfEmpty(directory) {
|
|
409
1166
|
try {
|
|
410
|
-
|
|
1167
|
+
rmdirSync2(directory);
|
|
411
1168
|
} catch {}
|
|
412
1169
|
}
|
|
413
1170
|
function uninstallPluginConfig(projectDirectory) {
|
|
414
1171
|
const existingPath = existingConfigPath(projectDirectory);
|
|
415
|
-
const configPath = existingPath ??
|
|
1172
|
+
const configPath = existingPath ?? path7.join(projectDirectory, "opencode.json");
|
|
416
1173
|
if (existingPath === undefined)
|
|
417
1174
|
return { status: "absent", configPath };
|
|
418
1175
|
const config = readConfig(configPath);
|
|
@@ -436,44 +1193,50 @@ function uninstallPluginConfig(projectDirectory) {
|
|
|
436
1193
|
return { status: "removed", configPath };
|
|
437
1194
|
}
|
|
438
1195
|
function uninstallSkill(projectDirectory, packageDirectory) {
|
|
439
|
-
const sourcePath =
|
|
440
|
-
const skillDirectory =
|
|
441
|
-
const skillPath =
|
|
442
|
-
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))
|
|
443
1200
|
return { status: "absent", skillPath };
|
|
444
|
-
if (
|
|
1201
|
+
if (readFileSync3(skillPath, "utf8") !== readFileSync3(sourcePath, "utf8")) {
|
|
445
1202
|
return { status: "kept-modified", skillPath };
|
|
446
1203
|
}
|
|
447
1204
|
rmSync2(skillDirectory, { recursive: true, force: true });
|
|
448
|
-
removeDirectoryIfEmpty(
|
|
449
|
-
removeDirectoryIfEmpty(
|
|
1205
|
+
removeDirectoryIfEmpty(path7.dirname(skillDirectory));
|
|
1206
|
+
removeDirectoryIfEmpty(path7.join(projectDirectory, ".opencode"));
|
|
450
1207
|
return { status: "removed", skillPath };
|
|
451
1208
|
}
|
|
452
1209
|
function purgeProjectData(projectDirectory) {
|
|
453
|
-
const abs =
|
|
1210
|
+
const abs = path7.resolve(projectDirectory);
|
|
454
1211
|
const scopeId = deriveScopeId(abs);
|
|
455
1212
|
const targets = [
|
|
456
1213
|
scopeDirectory(scopeId),
|
|
457
1214
|
runsDirectory(scopeId),
|
|
458
1215
|
sessionStateDirectory(scopeId),
|
|
459
1216
|
logDirectory(scopeId),
|
|
460
|
-
|
|
1217
|
+
locksDirectory(scopeId),
|
|
1218
|
+
worktreesDirectory(scopeId),
|
|
1219
|
+
path7.join(abs, ".opencode", "jobs")
|
|
461
1220
|
];
|
|
462
1221
|
const paths = [];
|
|
463
1222
|
for (const target of targets) {
|
|
464
|
-
if (!
|
|
1223
|
+
if (!existsSync4(target))
|
|
465
1224
|
continue;
|
|
466
1225
|
rmSync2(target, { recursive: true, force: true });
|
|
467
1226
|
paths.push(target);
|
|
468
1227
|
}
|
|
469
|
-
|
|
1228
|
+
if (paths.includes(worktreesDirectory(scopeId))) {
|
|
1229
|
+
spawnSync2("git", ["-C", abs, "worktree", "prune"], { stdio: "ignore" });
|
|
1230
|
+
}
|
|
1231
|
+
removeDirectoryIfEmpty(path7.join(abs, ".opencode"));
|
|
470
1232
|
return { paths };
|
|
471
1233
|
}
|
|
472
1234
|
function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
473
|
-
const resolvedProject =
|
|
474
|
-
if (!
|
|
1235
|
+
const resolvedProject = path7.resolve(projectDirectory);
|
|
1236
|
+
if (!existsSync4(resolvedProject) || !statSync(resolvedProject).isDirectory()) {
|
|
475
1237
|
throw new Error(`Project directory does not exist: ${resolvedProject}`);
|
|
476
1238
|
}
|
|
1239
|
+
const migration = migrateStorage(resolvedProject, false);
|
|
477
1240
|
const wasEnabled = registryEntry(resolvedProject) !== undefined;
|
|
478
1241
|
if (wasEnabled)
|
|
479
1242
|
disableProject(resolvedProject);
|
|
@@ -481,7 +1244,8 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
|
481
1244
|
projectDirectory: resolvedProject,
|
|
482
1245
|
disabled: wasEnabled,
|
|
483
1246
|
plugin: uninstallPluginConfig(resolvedProject),
|
|
484
|
-
skill: uninstallSkill(resolvedProject, packageDirectory)
|
|
1247
|
+
skill: uninstallSkill(resolvedProject, packageDirectory),
|
|
1248
|
+
...migration.moved.length > 0 && { migration }
|
|
485
1249
|
};
|
|
486
1250
|
if (shouldPurge)
|
|
487
1251
|
uninstall.purge = purgeProjectData(resolvedProject);
|
|
@@ -494,10 +1258,10 @@ var USAGE = `Usage: opencode-jobs <command> [projectDir]
|
|
|
494
1258
|
Commands:
|
|
495
1259
|
install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
|
|
496
1260
|
uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
|
|
497
|
-
--purge also deletes job definitions and
|
|
1261
|
+
--purge also deletes job definitions and job data
|
|
498
1262
|
help Show this help`;
|
|
499
1263
|
function packageDirectory() {
|
|
500
|
-
return
|
|
1264
|
+
return path8.resolve(path8.dirname(fileURLToPath(import.meta.url)), "..");
|
|
501
1265
|
}
|
|
502
1266
|
function printError(message) {
|
|
503
1267
|
console.error(`Error: ${message}
|