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/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -149,31 +149,31 @@ function stateRoot() {
|
|
|
149
149
|
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
150
150
|
}
|
|
151
151
|
function worktreesDirectory(scopeId) {
|
|
152
|
-
return path.join(stateRoot(), "opencode", "
|
|
152
|
+
return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
|
|
153
153
|
}
|
|
154
|
-
function
|
|
155
|
-
return path.join(configRoot(), "opencode", "
|
|
154
|
+
function jobsStateDirectory() {
|
|
155
|
+
return path.join(configRoot(), "opencode", "jobs");
|
|
156
156
|
}
|
|
157
157
|
function registryPath() {
|
|
158
|
-
return path.join(
|
|
158
|
+
return path.join(jobsStateDirectory(), "registry.json");
|
|
159
159
|
}
|
|
160
160
|
function scopeDirectory(scopeId) {
|
|
161
|
-
return path.join(
|
|
161
|
+
return path.join(jobsStateDirectory(), "scopes", scopeId);
|
|
162
162
|
}
|
|
163
163
|
function runsDirectory(scopeId) {
|
|
164
|
-
return path.join(
|
|
164
|
+
return path.join(jobsStateDirectory(), "runs", scopeId);
|
|
165
165
|
}
|
|
166
166
|
function runsFile(scopeId, slug) {
|
|
167
167
|
return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
|
|
168
168
|
}
|
|
169
169
|
function sessionStateDirectory(scopeId) {
|
|
170
|
-
return path.join(
|
|
170
|
+
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
171
171
|
}
|
|
172
172
|
function sessionStateFile(scopeId, slug) {
|
|
173
173
|
return path.join(sessionStateDirectory(scopeId), `${slug}.txt`);
|
|
174
174
|
}
|
|
175
175
|
function logDirectory(scopeId) {
|
|
176
|
-
return path.join(configRoot(), "opencode", "logs", "
|
|
176
|
+
return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
|
|
177
177
|
}
|
|
178
178
|
function logFile(scopeId, slug) {
|
|
179
179
|
return path.join(logDirectory(scopeId), `${slug}.log`);
|
|
@@ -182,7 +182,7 @@ function systemdUserDirectory() {
|
|
|
182
182
|
return path.join(configRoot(), "systemd", "user");
|
|
183
183
|
}
|
|
184
184
|
function jobsDirectory(workdir) {
|
|
185
|
-
return path.join(workdir, ".opencode", "
|
|
185
|
+
return path.join(workdir, ".opencode", "jobs");
|
|
186
186
|
}
|
|
187
187
|
function unitBase(scopeId, slug) {
|
|
188
188
|
return `opencode-sched-${scopeId}-${slug}`;
|
|
@@ -447,19 +447,22 @@ var registryFileSchema = z2.object({
|
|
|
447
447
|
version: z2.literal(1),
|
|
448
448
|
projects: z2.record(z2.string(), z2.unknown())
|
|
449
449
|
});
|
|
450
|
+
function readRegistryFile(file) {
|
|
451
|
+
const parsed = JSON.parse(readFileSync2(file, "utf8"));
|
|
452
|
+
const result = registryFileSchema.safeParse(parsed);
|
|
453
|
+
if (!result.success)
|
|
454
|
+
throw new Error(`Invalid job registry: ${file}`);
|
|
455
|
+
const projects = {};
|
|
456
|
+
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
457
|
+
const entry = registryEntrySchema.safeParse(value);
|
|
458
|
+
if (entry.success)
|
|
459
|
+
projects[key] = entry.data;
|
|
460
|
+
}
|
|
461
|
+
return { version: 1, projects };
|
|
462
|
+
}
|
|
450
463
|
function loadRegistry() {
|
|
451
464
|
try {
|
|
452
|
-
|
|
453
|
-
const result = registryFileSchema.safeParse(parsed);
|
|
454
|
-
if (!result.success)
|
|
455
|
-
return { version: 1, projects: {} };
|
|
456
|
-
const projects = {};
|
|
457
|
-
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
458
|
-
const entry = registryEntrySchema.safeParse(value);
|
|
459
|
-
if (entry.success)
|
|
460
|
-
projects[key] = entry.data;
|
|
461
|
-
}
|
|
462
|
-
return { version: 1, projects };
|
|
465
|
+
return readRegistryFile(registryPath());
|
|
463
466
|
} catch {
|
|
464
467
|
return { version: 1, projects: {} };
|
|
465
468
|
}
|
|
@@ -548,7 +551,7 @@ import { spawnSync } from "child_process";
|
|
|
548
551
|
import path4 from "path";
|
|
549
552
|
import { homedir as homedir2 } from "os";
|
|
550
553
|
function findOpencode() {
|
|
551
|
-
const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
554
|
+
const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
552
555
|
if (override !== undefined && override.length > 0)
|
|
553
556
|
return override;
|
|
554
557
|
const which = spawnSync("sh", ["-c", "command -v opencode"], {
|
|
@@ -581,24 +584,24 @@ function guardScriptLines(guard) {
|
|
|
581
584
|
];
|
|
582
585
|
}
|
|
583
586
|
function worktreeDefaultRoot(scopeId) {
|
|
584
|
-
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/
|
|
587
|
+
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
|
|
585
588
|
}
|
|
586
589
|
function worktreePrologueLines(job, scopeId) {
|
|
587
590
|
const base = job.worktree?.base;
|
|
588
591
|
return [
|
|
589
592
|
"wt_enabled=1",
|
|
590
593
|
'orig_pwd="$(pwd)"',
|
|
591
|
-
'lock_dir="$config_root/opencode/
|
|
594
|
+
'lock_dir="$config_root/opencode/jobs/locks/$scope"',
|
|
592
595
|
'mkdir -p "$lock_dir"',
|
|
593
596
|
'exec 9>"$lock_dir/$slug.lock"',
|
|
594
597
|
"if ! flock -n 9; then",
|
|
595
|
-
' echo "
|
|
598
|
+
' echo "opencode-jobs: another run of $slug is already active, skipping"',
|
|
596
599
|
" finish skipped 0",
|
|
597
600
|
" exit 0",
|
|
598
601
|
"fi",
|
|
599
602
|
base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
|
|
600
603
|
'if ! mkdir -p "$wt_root"; then',
|
|
601
|
-
' echo "
|
|
604
|
+
' echo "opencode-jobs: cannot create worktree base $wt_root"',
|
|
602
605
|
" finish failed 1",
|
|
603
606
|
" exit 1",
|
|
604
607
|
"fi",
|
|
@@ -613,31 +616,31 @@ function worktreePrologueLines(job, scopeId) {
|
|
|
613
616
|
" wt_stale_saved=1",
|
|
614
617
|
' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
|
|
615
618
|
' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
616
|
-
' git -C "$wt_path" -c user.name=opencode-jobs -c user.email=
|
|
619
|
+
' 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',
|
|
617
620
|
" fi",
|
|
618
621
|
' if [ "$wt_stale_saved" -eq 1 ]; then',
|
|
619
622
|
' git worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
620
623
|
' rm -rf "$wt_path"',
|
|
621
624
|
" else",
|
|
622
|
-
' echo "
|
|
625
|
+
' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
|
|
623
626
|
' wt_branch=""',
|
|
624
627
|
" finish failed 1",
|
|
625
628
|
" exit 1",
|
|
626
629
|
" fi",
|
|
627
630
|
" else",
|
|
628
|
-
' echo "
|
|
631
|
+
' echo "opencode-jobs: removing unexpected directory at $wt_path"',
|
|
629
632
|
' rm -rf "$wt_path"',
|
|
630
633
|
" fi",
|
|
631
634
|
" git worktree prune >/dev/null 2>&1",
|
|
632
635
|
"fi",
|
|
633
636
|
'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
|
|
634
|
-
' echo "
|
|
637
|
+
' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
|
|
635
638
|
' wt_branch=""',
|
|
636
639
|
" finish failed 1",
|
|
637
640
|
" exit 1",
|
|
638
641
|
"fi",
|
|
639
642
|
'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
|
|
640
|
-
' echo "
|
|
643
|
+
' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
|
|
641
644
|
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
642
645
|
' rm -rf "$wt_path"',
|
|
643
646
|
' wt_branch=""',
|
|
@@ -659,10 +662,10 @@ function worktreeEpilogueLines(options) {
|
|
|
659
662
|
' git -C "$wt_path" add -A >/dev/null 2>&1',
|
|
660
663
|
" wt_keep=0",
|
|
661
664
|
' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
662
|
-
' if git -C "$wt_path" -c user.name=opencode-jobs -c user.email=
|
|
663
|
-
' echo "
|
|
665
|
+
' 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',
|
|
666
|
+
' echo "opencode-jobs: committed worktree changes to branch $wt_branch"',
|
|
664
667
|
" else",
|
|
665
|
-
' echo "
|
|
668
|
+
' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
|
|
666
669
|
" wt_keep=1",
|
|
667
670
|
" fi",
|
|
668
671
|
" fi",
|
|
@@ -688,7 +691,7 @@ function compactSessionLines() {
|
|
|
688
691
|
"compact_session() {",
|
|
689
692
|
' csid="$1"',
|
|
690
693
|
" if ! command -v curl >/dev/null 2>&1; then",
|
|
691
|
-
' echo "
|
|
694
|
+
' echo "opencode-jobs: curl not available, skipping compaction"',
|
|
692
695
|
" return 0",
|
|
693
696
|
" fi",
|
|
694
697
|
' serve_out="$(mktemp)"',
|
|
@@ -705,7 +708,7 @@ function compactSessionLines() {
|
|
|
705
708
|
" tries=$((tries + 1))",
|
|
706
709
|
" done",
|
|
707
710
|
' if [ -z "$serve_port" ]; then',
|
|
708
|
-
' echo "
|
|
711
|
+
' echo "opencode-jobs: compaction server failed to start"',
|
|
709
712
|
' sed -n "1,10p" "$serve_err" >&2',
|
|
710
713
|
' kill "$serve_pid" 2>/dev/null',
|
|
711
714
|
' wait "$serve_pid" 2>/dev/null',
|
|
@@ -724,7 +727,7 @@ function compactSessionLines() {
|
|
|
724
727
|
" tries=$((tries + 1))",
|
|
725
728
|
" done",
|
|
726
729
|
' if [ "$healthy" -ne 1 ]; then',
|
|
727
|
-
' echo "
|
|
730
|
+
' echo "opencode-jobs: compaction server never became healthy"',
|
|
728
731
|
' kill "$serve_pid" 2>/dev/null',
|
|
729
732
|
' wait "$serve_pid" 2>/dev/null',
|
|
730
733
|
' rm -f "$serve_out" "$serve_err"',
|
|
@@ -744,22 +747,22 @@ function compactSessionLines() {
|
|
|
744
747
|
String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
|
|
745
748
|
" fi",
|
|
746
749
|
' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
|
|
747
|
-
' echo "
|
|
750
|
+
' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
|
|
748
751
|
' kill "$serve_pid" 2>/dev/null',
|
|
749
752
|
' wait "$serve_pid" 2>/dev/null',
|
|
750
753
|
' rm -f "$serve_out" "$serve_err"',
|
|
751
754
|
" return 0",
|
|
752
755
|
" fi",
|
|
753
|
-
' echo "
|
|
756
|
+
' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
|
|
754
757
|
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")"`,
|
|
755
758
|
' if [ "$result" != "true" ]; then',
|
|
756
|
-
' echo "
|
|
759
|
+
' echo "opencode-jobs: compaction failed: $result"',
|
|
757
760
|
" fi",
|
|
758
761
|
' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
|
|
759
762
|
String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
|
|
760
763
|
` 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")"`,
|
|
761
764
|
' if [ "$http" != "200" ]; then',
|
|
762
|
-
' echo "
|
|
765
|
+
' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
|
|
763
766
|
" fi",
|
|
764
767
|
" fi",
|
|
765
768
|
' kill "$serve_pid" 2>/dev/null',
|
|
@@ -780,25 +783,25 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
780
783
|
`scope=${shQuote(scopeId)}`,
|
|
781
784
|
`oc_bin=${shQuote(opencodeBin)}`,
|
|
782
785
|
'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
|
|
783
|
-
'runs="$config_root/opencode/
|
|
786
|
+
'runs="$config_root/opencode/jobs/runs/$scope"',
|
|
784
787
|
'mkdir -p "$runs"',
|
|
785
788
|
'record_file="$runs/$slug.jsonl"',
|
|
786
789
|
...isTracked ? [
|
|
787
|
-
'sessions="$config_root/opencode/
|
|
790
|
+
'sessions="$config_root/opencode/jobs/sessions/$scope"',
|
|
788
791
|
'mkdir -p "$sessions"',
|
|
789
792
|
'state_file="$sessions/$slug.txt"',
|
|
790
793
|
`session_mode=${shQuote(mode)}`,
|
|
791
794
|
'prev_session=""',
|
|
792
795
|
'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
|
|
793
796
|
] : [],
|
|
794
|
-
'started_by="${
|
|
797
|
+
'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
|
|
795
798
|
'run_id="$(date +%s%N)-$$"',
|
|
796
799
|
"started=$(date +%s)",
|
|
797
800
|
'new_session=""',
|
|
798
801
|
'wt_branch=""',
|
|
799
802
|
'wt_commit=""',
|
|
800
803
|
`export OPENCODE_PERMISSION='{"question":"deny"}'`,
|
|
801
|
-
'export
|
|
804
|
+
'export OPENCODE_JOBS_RUN_ID="$run_id"',
|
|
802
805
|
"finish() {",
|
|
803
806
|
' status="$1"',
|
|
804
807
|
' code="$2"',
|
|
@@ -839,7 +842,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
839
842
|
'cat "$json_out"',
|
|
840
843
|
...extractSessionIdLines("new_session"),
|
|
841
844
|
'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
|
|
842
|
-
' echo "
|
|
845
|
+
' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
|
|
843
846
|
' rm -f "$json_out"',
|
|
844
847
|
' json_out="$(mktemp)"',
|
|
845
848
|
' run_opencode "" 1 >"$json_out" 2>&1',
|
|
@@ -1203,7 +1206,7 @@ function scheduleJobOutput(input, directory) {
|
|
|
1203
1206
|
updatedAt: nowIso()
|
|
1204
1207
|
};
|
|
1205
1208
|
saveJob(directory, job);
|
|
1206
|
-
const relativePath = `.opencode/
|
|
1209
|
+
const relativePath = `.opencode/jobs/${slug}.json`;
|
|
1207
1210
|
const lines = [
|
|
1208
1211
|
`${existing.ok ? "Updated" : "Created"} job "${job.name}" (${slug})`,
|
|
1209
1212
|
`Definition: ${relativePath} (${job.schedule} \u2014 ${describeCron(sets)})`
|
|
@@ -1259,7 +1262,7 @@ function showJobOutput(slugInput, directory) {
|
|
|
1259
1262
|
const lines = [
|
|
1260
1263
|
`${job.name} (${job.slug})`,
|
|
1261
1264
|
`Schedule: ${job.schedule} \u2014 ${describeCron(sets)}`,
|
|
1262
|
-
`Definition: .opencode/
|
|
1265
|
+
`Definition: .opencode/jobs/${job.slug}.json (updated ${job.updatedAt})`,
|
|
1263
1266
|
`Run: ${runDesc}`
|
|
1264
1267
|
];
|
|
1265
1268
|
if (job.guard !== undefined)
|
|
@@ -1309,9 +1312,7 @@ function removeJobDefinitionOutput(slugInput, directory) {
|
|
|
1309
1312
|
if (!existsSync4(file))
|
|
1310
1313
|
return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
|
|
1311
1314
|
rmSync2(file);
|
|
1312
|
-
const lines = [
|
|
1313
|
-
`Deleted job definition .opencode/scheduler/jobs/${slug}.json`
|
|
1314
|
-
];
|
|
1315
|
+
const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
|
|
1315
1316
|
const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
|
|
1316
1317
|
const state = sessionStateFile(scopeId, slug);
|
|
1317
1318
|
if (existsSync4(state)) {
|
|
@@ -1359,7 +1360,7 @@ function runJobNowOutput(slugInput, directory) {
|
|
|
1359
1360
|
const fd = openSync(log, "a");
|
|
1360
1361
|
const child = spawn("/bin/sh", [script], {
|
|
1361
1362
|
cwd: path6.resolve(directory),
|
|
1362
|
-
env: { ...process.env,
|
|
1363
|
+
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1363
1364
|
stdio: ["ignore", fd, fd]
|
|
1364
1365
|
});
|
|
1365
1366
|
child.unref();
|
|
@@ -1393,7 +1394,7 @@ function listProjectsOutput() {
|
|
|
1393
1394
|
const entries = Object.values(registry.projects).toSorted((a, b) => a.workdir.localeCompare(b.workdir));
|
|
1394
1395
|
if (entries.length === 0)
|
|
1395
1396
|
return ok("No projects with scheduled jobs are registered.");
|
|
1396
|
-
const lines = ["Registry: ~/.config/opencode/
|
|
1397
|
+
const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
|
|
1397
1398
|
for (const entry of entries) {
|
|
1398
1399
|
const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
|
|
1399
1400
|
lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
|
|
@@ -1402,12 +1403,12 @@ function listProjectsOutput() {
|
|
|
1402
1403
|
`));
|
|
1403
1404
|
}
|
|
1404
1405
|
var listJobsTool = tool({
|
|
1405
|
-
description: "List scheduled job definitions for the current project (from .opencode/
|
|
1406
|
+
description: "List scheduled job definitions for the current project (from .opencode/jobs/), including enabled state, next run, and last run status.",
|
|
1406
1407
|
args: {},
|
|
1407
1408
|
execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
|
|
1408
1409
|
});
|
|
1409
1410
|
var scheduleJobTool = tool({
|
|
1410
|
-
description: "Create or update a scheduled job definition in the current project (.opencode/
|
|
1411
|
+
description: "Create or update a scheduled job definition in the current project (.opencode/jobs/<slug>.json, git-committable). Schedule is a 5-field cron expression. Set either prompt (natural language) or command (custom command name). If the project is enabled, systemd units are re-synced automatically.",
|
|
1411
1412
|
args: {
|
|
1412
1413
|
name: tool.schema.string().describe("Human-readable job name"),
|
|
1413
1414
|
schedule: tool.schema.string().describe('5-field cron expression, e.g. "0 9 * * *" (daily 9am), "0 */6 * * *" (every 6h), "30 8 * * 1" (Mon 8:30)'),
|
|
@@ -1417,7 +1418,7 @@ var scheduleJobTool = tool({
|
|
|
1417
1418
|
session: tool.schema.string().optional().describe(`Session continuity between runs: "new" (default, fresh session each run), "persist" (continue the same session), "compact" (continue the same session; after each run the history is compacted into a summary the next run starts from), "compact+last" (like compact, but the run's final result message is re-injected after the summary so the next run starts from summary plus last result)`),
|
|
1418
1419
|
guard: tool.schema.string().optional().describe('Shell command run before the job; the run only starts if it exits 0, otherwise it is recorded as skipped (applies to run_job too). E.g. "! git diff --quiet" to run only when the repo has changes'),
|
|
1419
1420
|
worktree: tool.schema.boolean().optional().describe("Run the job in a fresh git worktree instead of the project checkout: the worktree is created from worktreeRef (default HEAD), the job runs inside it, all changes are committed to a per-run branch opencode-jobs/<slug>/\u2026, and the worktree is removed afterwards (kept if the safety commit fails). Requires the project to be a git repository"),
|
|
1420
|
-
worktreeBase: tool.schema.string().optional().describe("Parent directory for the worktree (default: ~/.local/state/opencode/
|
|
1421
|
+
worktreeBase: tool.schema.string().optional().describe("Parent directory for the worktree (default: ~/.local/state/opencode/jobs/worktrees/<scopeId>/<slug>). Relative paths resolve against the project directory; it should be dedicated to job worktrees"),
|
|
1421
1422
|
worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
|
|
1422
1423
|
worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
|
|
1423
1424
|
agent: tool.schema.string().optional().describe("Agent to use for the run"),
|
|
@@ -1457,7 +1458,7 @@ var jobLogsTool = tool({
|
|
|
1457
1458
|
execute: (input, context) => Promise.resolve(jobLogsOutput(input.slug, input.lines, context.directory))
|
|
1458
1459
|
});
|
|
1459
1460
|
var enableProjectTool = tool({
|
|
1460
|
-
description: "Enable scheduled jobs for the current project: installs a systemd user service+timer per job definition in .opencode/
|
|
1461
|
+
description: "Enable scheduled jobs for the current project: installs a systemd user service+timer per job definition in .opencode/jobs/, registers the project in the global registry (~/.config/opencode/jobs/registry.json), and removes stale units for deleted jobs. Idempotent, so it also re-syncs after job definitions change. Linux only.",
|
|
1461
1462
|
args: {},
|
|
1462
1463
|
execute: (_input, context) => Promise.resolve(enableProjectOutput(context.directory))
|
|
1463
1464
|
});
|
|
@@ -1481,11 +1482,11 @@ function disableProjectOutput(directory) {
|
|
|
1481
1482
|
}
|
|
1482
1483
|
}
|
|
1483
1484
|
var listProjectsTool = tool({
|
|
1484
|
-
description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/
|
|
1485
|
+
description: "List all projects with enabled scheduled jobs from the global registry (~/.config/opencode/jobs/registry.json).",
|
|
1485
1486
|
args: {},
|
|
1486
1487
|
execute: () => Promise.resolve(listProjectsOutput())
|
|
1487
1488
|
});
|
|
1488
|
-
var
|
|
1489
|
+
var jobsTools = {
|
|
1489
1490
|
schedule_job: scheduleJobTool,
|
|
1490
1491
|
list_jobs: listJobsTool,
|
|
1491
1492
|
get_job: showJobTool,
|
|
@@ -1497,12 +1498,102 @@ var schedulerTools = {
|
|
|
1497
1498
|
list_projects: listProjectsTool
|
|
1498
1499
|
};
|
|
1499
1500
|
|
|
1501
|
+
// src/migration.ts
|
|
1502
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
|
|
1503
|
+
import path7 from "path";
|
|
1504
|
+
function legacyJobsStateDirectory() {
|
|
1505
|
+
return path7.join(configRoot(), "opencode", "scheduler");
|
|
1506
|
+
}
|
|
1507
|
+
function legacyRegistryPath() {
|
|
1508
|
+
return path7.join(legacyJobsStateDirectory(), "registry.json");
|
|
1509
|
+
}
|
|
1510
|
+
function legacyDefinitionsDirectory(workdir) {
|
|
1511
|
+
return path7.join(workdir, ".opencode", "scheduler", "jobs");
|
|
1512
|
+
}
|
|
1513
|
+
function migrationMoves(projects) {
|
|
1514
|
+
return [
|
|
1515
|
+
{
|
|
1516
|
+
from: legacyJobsStateDirectory(),
|
|
1517
|
+
to: jobsStateDirectory()
|
|
1518
|
+
},
|
|
1519
|
+
{
|
|
1520
|
+
from: path7.join(configRoot(), "opencode", "logs", "scheduler"),
|
|
1521
|
+
to: path7.join(configRoot(), "opencode", "logs", "jobs")
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
from: path7.join(stateRoot(), "opencode", "scheduler", "worktrees"),
|
|
1525
|
+
to: path7.join(stateRoot(), "opencode", "jobs", "worktrees")
|
|
1526
|
+
},
|
|
1527
|
+
...[...projects].map((workdir) => ({
|
|
1528
|
+
from: legacyDefinitionsDirectory(workdir),
|
|
1529
|
+
to: jobsDirectory(workdir)
|
|
1530
|
+
}))
|
|
1531
|
+
];
|
|
1532
|
+
}
|
|
1533
|
+
function removeLegacyProjectDirectory(workdir) {
|
|
1534
|
+
try {
|
|
1535
|
+
rmdirSync(path7.join(workdir, ".opencode", "scheduler"));
|
|
1536
|
+
} catch {}
|
|
1537
|
+
}
|
|
1538
|
+
function migrateStorage(projectDirectory, shouldResync) {
|
|
1539
|
+
const project = path7.resolve(projectDirectory);
|
|
1540
|
+
const legacyRegistry = legacyRegistryPath();
|
|
1541
|
+
const canonicalRegistry = path7.join(jobsStateDirectory(), "registry.json");
|
|
1542
|
+
const registryFile = existsSync5(legacyRegistry) ? legacyRegistry : canonicalRegistry;
|
|
1543
|
+
const registry = existsSync5(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
|
|
1544
|
+
const registeredProjects = new Set(Object.keys(registry.projects));
|
|
1545
|
+
const projects = new Set([project, ...registeredProjects]);
|
|
1546
|
+
const moves = migrationMoves(projects).filter(({ from }) => existsSync5(from));
|
|
1547
|
+
for (const { from, to } of moves) {
|
|
1548
|
+
if (existsSync5(to)) {
|
|
1549
|
+
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.`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
for (const { from, to } of moves) {
|
|
1553
|
+
mkdirSync4(path7.dirname(to), { recursive: true });
|
|
1554
|
+
renameSync2(from, to);
|
|
1555
|
+
}
|
|
1556
|
+
for (const workdir of projects)
|
|
1557
|
+
removeLegacyProjectDirectory(workdir);
|
|
1558
|
+
const result = {
|
|
1559
|
+
moved: moves,
|
|
1560
|
+
resyncedProjects: [],
|
|
1561
|
+
warnings: []
|
|
1562
|
+
};
|
|
1563
|
+
if (!shouldResync || moves.length === 0)
|
|
1564
|
+
return result;
|
|
1565
|
+
for (const workdir of registeredProjects) {
|
|
1566
|
+
try {
|
|
1567
|
+
enableProject(workdir);
|
|
1568
|
+
result.resyncedProjects.push(workdir);
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1571
|
+
result.warnings.push(`${workdir}: ${message}`);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return result;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1500
1577
|
// src/index.ts
|
|
1501
|
-
var src_default = () => {
|
|
1578
|
+
var src_default = (input) => {
|
|
1579
|
+
if (typeof input.directory === "string") {
|
|
1580
|
+
try {
|
|
1581
|
+
const migration = migrateStorage(input.directory, true);
|
|
1582
|
+
if (migration.moved.length > 0) {
|
|
1583
|
+
console.error(`[opencode-jobs] migrated ${String(migration.moved.length)} legacy storage location(s) to jobs paths`);
|
|
1584
|
+
}
|
|
1585
|
+
for (const warning of migration.warnings) {
|
|
1586
|
+
console.error(`[opencode-jobs] migration warning: ${warning}`);
|
|
1587
|
+
}
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1590
|
+
console.error(`[opencode-jobs] storage migration failed: ${message}`);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1502
1593
|
if (process.platform !== "linux") {
|
|
1503
1594
|
console.error("[opencode-jobs] warning: this is not a Linux host, systemd user timers are unavailable \u2014 scheduling tools will not work here");
|
|
1504
1595
|
}
|
|
1505
|
-
return Promise.resolve({ tool:
|
|
1596
|
+
return Promise.resolve({ tool: jobsTools });
|
|
1506
1597
|
};
|
|
1507
1598
|
export {
|
|
1508
1599
|
src_default as default
|
package/dist/install.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type StorageMigration } from "./migration.js";
|
|
1
2
|
export declare const PACKAGE_NAME = "opencode-jobs";
|
|
2
3
|
export declare const SKILL_NAME = "opencode-jobs";
|
|
3
4
|
export type ConfigInstall = {
|
|
@@ -15,6 +16,7 @@ export interface ProjectInstall {
|
|
|
15
16
|
projectDirectory: string;
|
|
16
17
|
plugin: ConfigInstall;
|
|
17
18
|
skill: SkillInstall;
|
|
19
|
+
migration?: StorageMigration;
|
|
18
20
|
}
|
|
19
21
|
export declare function installPluginConfig(projectDirectory: string): ConfigInstall;
|
|
20
22
|
export declare function installSkill(projectDirectory: string, packageDirectory: string): SkillInstall;
|
|
@@ -39,6 +41,7 @@ export interface ProjectUninstall {
|
|
|
39
41
|
plugin: ConfigUninstall;
|
|
40
42
|
skill: SkillUninstall;
|
|
41
43
|
purge?: PurgeResult;
|
|
44
|
+
migration?: StorageMigration;
|
|
42
45
|
}
|
|
43
46
|
export declare function uninstallPluginConfig(projectDirectory: string): ConfigUninstall;
|
|
44
47
|
export declare function uninstallSkill(projectDirectory: string, packageDirectory: string): SkillUninstall;
|
package/dist/paths.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export declare function configRoot(): string;
|
|
|
2
2
|
export declare function stateRoot(): string;
|
|
3
3
|
export declare function worktreesDirectory(scopeId: string): string;
|
|
4
4
|
export declare function locksDirectory(scopeId: string): string;
|
|
5
|
-
export declare function
|
|
5
|
+
export declare function jobsStateDirectory(): string;
|
|
6
6
|
export declare function registryPath(): string;
|
|
7
7
|
export declare function scopeDirectory(scopeId: string): string;
|
|
8
8
|
export declare function runsDirectory(scopeId: string): string;
|
package/dist/registry.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Registry {
|
|
|
9
9
|
version: 1;
|
|
10
10
|
projects: Record<string, RegistryEntry>;
|
|
11
11
|
}
|
|
12
|
+
export declare function readRegistryFile(file: string): Registry;
|
|
12
13
|
export declare function loadRegistry(): Registry;
|
|
13
14
|
export declare function saveRegistry(registry: Registry): void;
|
|
14
15
|
export declare function registryEntry(workdir: string): RegistryEntry | undefined;
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { type ToolDefinition } from "@opencode-ai/plugin";
|
|
2
|
-
export declare const
|
|
2
|
+
export declare const jobsTools: Record<string, ToolDefinition>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "opencode plugin that schedules recurring agent jobs as systemd user timers, with git-committable job definitions, run history, and session continuity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -7,13 +7,13 @@ compatibility: opencode on Linux with systemd
|
|
|
7
7
|
|
|
8
8
|
# OpenCode Jobs
|
|
9
9
|
|
|
10
|
-
Use the `opencode-jobs` tools instead of editing systemd units or global
|
|
11
|
-
|
|
10
|
+
Use the `opencode-jobs` tools instead of editing systemd units or global job
|
|
11
|
+
state directly.
|
|
12
12
|
|
|
13
13
|
## Workflow
|
|
14
14
|
|
|
15
15
|
1. Create or update a definition with `schedule_job`. Definitions belong in
|
|
16
|
-
`.opencode/
|
|
16
|
+
`.opencode/jobs/<slug>.json` and should be committed with the
|
|
17
17
|
project.
|
|
18
18
|
2. Inspect definitions with `list_jobs` or `get_job`.
|
|
19
19
|
3. Run `enable_project` after the first job is created. Run it again to
|
|
@@ -36,7 +36,7 @@ scheduler state directly.
|
|
|
36
36
|
- A `guard` is a shell command that must exit zero for the job to run.
|
|
37
37
|
- Set `worktree: true` when the job should not touch the user's checkout:
|
|
38
38
|
each run gets a fresh git worktree (default base
|
|
39
|
-
`~/.local/state/opencode/
|
|
39
|
+
`~/.local/state/opencode/jobs/worktrees/…`, override with
|
|
40
40
|
`worktree.base`), all changes are committed to a per-run branch
|
|
41
41
|
`opencode-jobs/<slug>/…`, and the worktree is removed. Overlapping runs
|
|
42
42
|
of the same job are skipped via a lock; subdirectory projects run in
|