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/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -145,29 +145,35 @@ import { homedir } from "os";
|
|
|
145
145
|
function configRoot() {
|
|
146
146
|
return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
|
|
147
147
|
}
|
|
148
|
-
function
|
|
149
|
-
return path.join(
|
|
148
|
+
function stateRoot() {
|
|
149
|
+
return process.env.XDG_STATE_HOME ?? path.join(homedir(), ".local", "state");
|
|
150
|
+
}
|
|
151
|
+
function worktreesDirectory(scopeId) {
|
|
152
|
+
return path.join(stateRoot(), "opencode", "jobs", "worktrees", scopeId);
|
|
153
|
+
}
|
|
154
|
+
function jobsStateDirectory() {
|
|
155
|
+
return path.join(configRoot(), "opencode", "jobs");
|
|
150
156
|
}
|
|
151
157
|
function registryPath() {
|
|
152
|
-
return path.join(
|
|
158
|
+
return path.join(jobsStateDirectory(), "registry.json");
|
|
153
159
|
}
|
|
154
160
|
function scopeDirectory(scopeId) {
|
|
155
|
-
return path.join(
|
|
161
|
+
return path.join(jobsStateDirectory(), "scopes", scopeId);
|
|
156
162
|
}
|
|
157
163
|
function runsDirectory(scopeId) {
|
|
158
|
-
return path.join(
|
|
164
|
+
return path.join(jobsStateDirectory(), "runs", scopeId);
|
|
159
165
|
}
|
|
160
166
|
function runsFile(scopeId, slug) {
|
|
161
167
|
return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
|
|
162
168
|
}
|
|
163
169
|
function sessionStateDirectory(scopeId) {
|
|
164
|
-
return path.join(
|
|
170
|
+
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
165
171
|
}
|
|
166
172
|
function sessionStateFile(scopeId, slug) {
|
|
167
173
|
return path.join(sessionStateDirectory(scopeId), `${slug}.txt`);
|
|
168
174
|
}
|
|
169
175
|
function logDirectory(scopeId) {
|
|
170
|
-
return path.join(configRoot(), "opencode", "logs", "
|
|
176
|
+
return path.join(configRoot(), "opencode", "logs", "jobs", scopeId);
|
|
171
177
|
}
|
|
172
178
|
function logFile(scopeId, slug) {
|
|
173
179
|
return path.join(logDirectory(scopeId), `${slug}.log`);
|
|
@@ -176,7 +182,7 @@ function systemdUserDirectory() {
|
|
|
176
182
|
return path.join(configRoot(), "systemd", "user");
|
|
177
183
|
}
|
|
178
184
|
function jobsDirectory(workdir) {
|
|
179
|
-
return path.join(workdir, ".opencode", "
|
|
185
|
+
return path.join(workdir, ".opencode", "jobs");
|
|
180
186
|
}
|
|
181
187
|
function unitBase(scopeId, slug) {
|
|
182
188
|
return `opencode-sched-${scopeId}-${slug}`;
|
|
@@ -281,6 +287,24 @@ var sessionSchema = z.enum(SESSION_MODES, {
|
|
|
281
287
|
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
282
288
|
});
|
|
283
289
|
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
290
|
+
var worktreeSchema = z.union([
|
|
291
|
+
z.literal(true),
|
|
292
|
+
z.strictObject({
|
|
293
|
+
base: nonEmptyStringSchema.optional(),
|
|
294
|
+
ref: nonEmptyStringSchema.optional(),
|
|
295
|
+
commitMessage: nonEmptyStringSchema.optional()
|
|
296
|
+
})
|
|
297
|
+
]).transform((value) => {
|
|
298
|
+
if (value === true)
|
|
299
|
+
return {};
|
|
300
|
+
return {
|
|
301
|
+
...value.base !== undefined && { base: value.base },
|
|
302
|
+
...value.ref !== undefined && { ref: value.ref },
|
|
303
|
+
...value.commitMessage !== undefined && {
|
|
304
|
+
commitMessage: value.commitMessage
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
});
|
|
284
308
|
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
285
309
|
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
286
310
|
try {
|
|
@@ -296,6 +320,7 @@ var jobFileSchema = z.strictObject({
|
|
|
296
320
|
run: runSpecSchema,
|
|
297
321
|
session: sessionSchema.default("new"),
|
|
298
322
|
guard: guardSchema.optional(),
|
|
323
|
+
worktree: worktreeSchema.optional(),
|
|
299
324
|
timeoutSeconds: timeoutSchema.optional(),
|
|
300
325
|
createdAt: z.string().optional(),
|
|
301
326
|
updatedAt: z.string().optional()
|
|
@@ -332,6 +357,11 @@ function validateGuard(value, context) {
|
|
|
332
357
|
return;
|
|
333
358
|
return parseWithContext(guardSchema, value, context);
|
|
334
359
|
}
|
|
360
|
+
function validateWorktree(value, context) {
|
|
361
|
+
if (value === undefined)
|
|
362
|
+
return;
|
|
363
|
+
return parseWithContext(worktreeSchema, value, context);
|
|
364
|
+
}
|
|
335
365
|
function loadJobFile(file, expectedSlug) {
|
|
336
366
|
const stem = path2.basename(file, ".json");
|
|
337
367
|
try {
|
|
@@ -362,6 +392,9 @@ function loadJobFile(file, expectedSlug) {
|
|
|
362
392
|
run: definition.run,
|
|
363
393
|
...definition.session !== "new" && { session: definition.session },
|
|
364
394
|
...definition.guard !== undefined && { guard: definition.guard },
|
|
395
|
+
...definition.worktree !== undefined && {
|
|
396
|
+
worktree: definition.worktree
|
|
397
|
+
},
|
|
365
398
|
...definition.timeoutSeconds !== undefined && {
|
|
366
399
|
timeoutSeconds: definition.timeoutSeconds
|
|
367
400
|
},
|
|
@@ -414,19 +447,22 @@ var registryFileSchema = z2.object({
|
|
|
414
447
|
version: z2.literal(1),
|
|
415
448
|
projects: z2.record(z2.string(), z2.unknown())
|
|
416
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
|
+
}
|
|
417
463
|
function loadRegistry() {
|
|
418
464
|
try {
|
|
419
|
-
|
|
420
|
-
const result = registryFileSchema.safeParse(parsed);
|
|
421
|
-
if (!result.success)
|
|
422
|
-
return { version: 1, projects: {} };
|
|
423
|
-
const projects = {};
|
|
424
|
-
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
425
|
-
const entry = registryEntrySchema.safeParse(value);
|
|
426
|
-
if (entry.success)
|
|
427
|
-
projects[key] = entry.data;
|
|
428
|
-
}
|
|
429
|
-
return { version: 1, projects };
|
|
465
|
+
return readRegistryFile(registryPath());
|
|
430
466
|
} catch {
|
|
431
467
|
return { version: 1, projects: {} };
|
|
432
468
|
}
|
|
@@ -454,7 +490,9 @@ var runRecordSchema = z3.object({
|
|
|
454
490
|
status: optionalString,
|
|
455
491
|
exitCode: optionalNumber,
|
|
456
492
|
sessionId: optionalString,
|
|
457
|
-
startedBy: optionalString
|
|
493
|
+
startedBy: optionalString,
|
|
494
|
+
worktreeBranch: optionalString,
|
|
495
|
+
worktreeCommit: optionalString
|
|
458
496
|
});
|
|
459
497
|
function readRunRecords(scopeId, slug, limit) {
|
|
460
498
|
const file = runsFile(scopeId, slug);
|
|
@@ -492,7 +530,8 @@ function formatRunLine(record) {
|
|
|
492
530
|
const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
|
|
493
531
|
const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
|
|
494
532
|
const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
|
|
495
|
-
|
|
533
|
+
const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
|
|
534
|
+
return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
|
|
496
535
|
}
|
|
497
536
|
function tailFile(file, lines, maxChars) {
|
|
498
537
|
if (!existsSync2(file))
|
|
@@ -512,7 +551,7 @@ import { spawnSync } from "child_process";
|
|
|
512
551
|
import path4 from "path";
|
|
513
552
|
import { homedir as homedir2 } from "os";
|
|
514
553
|
function findOpencode() {
|
|
515
|
-
const override = process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
554
|
+
const override = process.env.OPENCODE_JOBS_OPENCODE_PATH ?? process.env.OPENCODE_SCHEDULER_OPENCODE_PATH;
|
|
516
555
|
if (override !== undefined && override.length > 0)
|
|
517
556
|
return override;
|
|
518
557
|
const which = spawnSync("sh", ["-c", "command -v opencode"], {
|
|
@@ -544,6 +583,101 @@ function guardScriptLines(guard) {
|
|
|
544
583
|
"fi"
|
|
545
584
|
];
|
|
546
585
|
}
|
|
586
|
+
function worktreeDefaultRoot(scopeId) {
|
|
587
|
+
return "${XDG_STATE_HOME:-$HOME/.local/state}/opencode/jobs/worktrees/" + scopeId;
|
|
588
|
+
}
|
|
589
|
+
function worktreePrologueLines(job, scopeId) {
|
|
590
|
+
const base = job.worktree?.base;
|
|
591
|
+
return [
|
|
592
|
+
"wt_enabled=1",
|
|
593
|
+
'orig_pwd="$(pwd)"',
|
|
594
|
+
'lock_dir="$config_root/opencode/jobs/locks/$scope"',
|
|
595
|
+
'mkdir -p "$lock_dir"',
|
|
596
|
+
'exec 9>"$lock_dir/$slug.lock"',
|
|
597
|
+
"if ! flock -n 9; then",
|
|
598
|
+
' echo "opencode-jobs: another run of $slug is already active, skipping"',
|
|
599
|
+
" finish skipped 0",
|
|
600
|
+
" exit 0",
|
|
601
|
+
"fi",
|
|
602
|
+
base === undefined ? `wt_root="${worktreeDefaultRoot(scopeId)}"` : `wt_root=${shQuote(base)}`,
|
|
603
|
+
'if ! mkdir -p "$wt_root"; then',
|
|
604
|
+
' echo "opencode-jobs: cannot create worktree base $wt_root"',
|
|
605
|
+
" finish failed 1",
|
|
606
|
+
" exit 1",
|
|
607
|
+
"fi",
|
|
608
|
+
'wt_root="$(cd "$wt_root" && pwd)"',
|
|
609
|
+
'wt_path="$wt_root/$slug"',
|
|
610
|
+
'wt_branch="opencode-jobs/$slug/$(date +%Y%m%d-%H%M%S)-$$"',
|
|
611
|
+
`wt_base_ref=${shQuote(job.worktree?.ref ?? "HEAD")}`,
|
|
612
|
+
'wt_sub="$(git rev-parse --show-prefix 2>/dev/null)"',
|
|
613
|
+
'wt_sub="${wt_sub%/}"',
|
|
614
|
+
'if [ -d "$wt_path" ]; then',
|
|
615
|
+
' if git worktree list --porcelain 2>/dev/null | grep -qFx "worktree $wt_path"; then',
|
|
616
|
+
" wt_stale_saved=1",
|
|
617
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1 || wt_stale_saved=0',
|
|
618
|
+
' if [ "$wt_stale_saved" -eq 1 ] && ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
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',
|
|
620
|
+
" fi",
|
|
621
|
+
' if [ "$wt_stale_saved" -eq 1 ]; then',
|
|
622
|
+
' git worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
623
|
+
' rm -rf "$wt_path"',
|
|
624
|
+
" else",
|
|
625
|
+
' echo "opencode-jobs: cannot save changes in stale worktree $wt_path; keeping it and aborting this run"',
|
|
626
|
+
' wt_branch=""',
|
|
627
|
+
" finish failed 1",
|
|
628
|
+
" exit 1",
|
|
629
|
+
" fi",
|
|
630
|
+
" else",
|
|
631
|
+
' echo "opencode-jobs: removing unexpected directory at $wt_path"',
|
|
632
|
+
' rm -rf "$wt_path"',
|
|
633
|
+
" fi",
|
|
634
|
+
" git worktree prune >/dev/null 2>&1",
|
|
635
|
+
"fi",
|
|
636
|
+
'if ! git worktree add -b "$wt_branch" "$wt_path" "$wt_base_ref"; then',
|
|
637
|
+
' echo "opencode-jobs: failed to create worktree $wt_path (worktree jobs require a git repository)"',
|
|
638
|
+
' wt_branch=""',
|
|
639
|
+
" finish failed 1",
|
|
640
|
+
" exit 1",
|
|
641
|
+
"fi",
|
|
642
|
+
'if [ -n "$wt_sub" ] && [ ! -d "$wt_path/$wt_sub" ]; then',
|
|
643
|
+
' echo "opencode-jobs: project subdirectory $wt_sub is missing from the worktree at $wt_base_ref"',
|
|
644
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1',
|
|
645
|
+
' rm -rf "$wt_path"',
|
|
646
|
+
' wt_branch=""',
|
|
647
|
+
" finish failed 1",
|
|
648
|
+
" exit 1",
|
|
649
|
+
"fi",
|
|
650
|
+
'if [ -n "$wt_sub" ]; then',
|
|
651
|
+
' cd "$wt_path/$wt_sub" || { finish failed 1; exit 1; }',
|
|
652
|
+
"else",
|
|
653
|
+
' cd "$wt_path" || { finish failed 1; exit 1; }',
|
|
654
|
+
"fi"
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
function worktreeEpilogueLines(options) {
|
|
658
|
+
const message = options.commitMessage === undefined ? '"opencode-jobs: $slug run $run_id"' : shQuote(options.commitMessage);
|
|
659
|
+
return [
|
|
660
|
+
'if [ "$wt_enabled" -eq 1 ]; then',
|
|
661
|
+
` wt_msg=${message}`,
|
|
662
|
+
' git -C "$wt_path" add -A >/dev/null 2>&1',
|
|
663
|
+
" wt_keep=0",
|
|
664
|
+
' if ! git -C "$wt_path" diff --cached --quiet >/dev/null 2>&1; then',
|
|
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"',
|
|
667
|
+
" else",
|
|
668
|
+
' echo "opencode-jobs: worktree commit failed, keeping worktree at $wt_path"',
|
|
669
|
+
" wt_keep=1",
|
|
670
|
+
" fi",
|
|
671
|
+
" fi",
|
|
672
|
+
' wt_commit="$(git -C "$wt_path" rev-parse HEAD 2>/dev/null)"',
|
|
673
|
+
' if [ "$wt_keep" -eq 0 ]; then',
|
|
674
|
+
' cd "$wt_root" 2>/dev/null',
|
|
675
|
+
' git -C "$orig_pwd" worktree remove --force "$wt_path" >/dev/null 2>&1 || rm -rf "$wt_path"',
|
|
676
|
+
' git -C "$orig_pwd" worktree prune >/dev/null 2>&1',
|
|
677
|
+
" fi",
|
|
678
|
+
"fi"
|
|
679
|
+
];
|
|
680
|
+
}
|
|
547
681
|
var SESSION_ID_SED = String.raw`s/.*"sessionID":"\([^"]*\)".*/\1/p`;
|
|
548
682
|
var DEFAULT_PROVIDER_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
549
683
|
var DEFAULT_MODEL_SED = String.raw`s/.*"default"[[:space:]]*:[[:space:]]*{[^}]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\2/p`;
|
|
@@ -557,7 +691,7 @@ function compactSessionLines() {
|
|
|
557
691
|
"compact_session() {",
|
|
558
692
|
' csid="$1"',
|
|
559
693
|
" if ! command -v curl >/dev/null 2>&1; then",
|
|
560
|
-
' echo "
|
|
694
|
+
' echo "opencode-jobs: curl not available, skipping compaction"',
|
|
561
695
|
" return 0",
|
|
562
696
|
" fi",
|
|
563
697
|
' serve_out="$(mktemp)"',
|
|
@@ -574,7 +708,7 @@ function compactSessionLines() {
|
|
|
574
708
|
" tries=$((tries + 1))",
|
|
575
709
|
" done",
|
|
576
710
|
' if [ -z "$serve_port" ]; then',
|
|
577
|
-
' echo "
|
|
711
|
+
' echo "opencode-jobs: compaction server failed to start"',
|
|
578
712
|
' sed -n "1,10p" "$serve_err" >&2',
|
|
579
713
|
' kill "$serve_pid" 2>/dev/null',
|
|
580
714
|
' wait "$serve_pid" 2>/dev/null',
|
|
@@ -593,7 +727,7 @@ function compactSessionLines() {
|
|
|
593
727
|
" tries=$((tries + 1))",
|
|
594
728
|
" done",
|
|
595
729
|
' if [ "$healthy" -ne 1 ]; then',
|
|
596
|
-
' echo "
|
|
730
|
+
' echo "opencode-jobs: compaction server never became healthy"',
|
|
597
731
|
' kill "$serve_pid" 2>/dev/null',
|
|
598
732
|
' wait "$serve_pid" 2>/dev/null',
|
|
599
733
|
' rm -f "$serve_out" "$serve_err"',
|
|
@@ -613,22 +747,22 @@ function compactSessionLines() {
|
|
|
613
747
|
String.raw` cs_model="$(printf '%s\n' "$defaults" | sed -n '${DEFAULT_MODEL_SED}' | head -n 1)"`,
|
|
614
748
|
" fi",
|
|
615
749
|
' if [ -z "$cs_provider" ] || [ -z "$cs_model" ]; then',
|
|
616
|
-
' echo "
|
|
750
|
+
' echo "opencode-jobs: could not resolve a model for compaction, skipping"',
|
|
617
751
|
' kill "$serve_pid" 2>/dev/null',
|
|
618
752
|
' wait "$serve_pid" 2>/dev/null',
|
|
619
753
|
' rm -f "$serve_out" "$serve_err"',
|
|
620
754
|
" return 0",
|
|
621
755
|
" fi",
|
|
622
|
-
' echo "
|
|
756
|
+
' echo "opencode-jobs: compacting session $csid (mode: $session_mode)"',
|
|
623
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")"`,
|
|
624
758
|
' if [ "$result" != "true" ]; then',
|
|
625
|
-
' echo "
|
|
759
|
+
' echo "opencode-jobs: compaction failed: $result"',
|
|
626
760
|
" fi",
|
|
627
761
|
' if [ "$result" = "true" ] && [ "$oc_keep_last" -eq 1 ] && [ -n "$cs_text" ]; then',
|
|
628
762
|
String.raw` inject_body="{\"noReply\":true,\"parts\":[{\"type\":\"text\",\"text\":\"$cs_text\"}]}"`,
|
|
629
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")"`,
|
|
630
764
|
' if [ "$http" != "200" ]; then',
|
|
631
|
-
' echo "
|
|
765
|
+
' echo "opencode-jobs: keeping last result failed (HTTP $http)"',
|
|
632
766
|
" fi",
|
|
633
767
|
" fi",
|
|
634
768
|
' kill "$serve_pid" 2>/dev/null',
|
|
@@ -649,32 +783,35 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
649
783
|
`scope=${shQuote(scopeId)}`,
|
|
650
784
|
`oc_bin=${shQuote(opencodeBin)}`,
|
|
651
785
|
'config_root="${XDG_CONFIG_HOME:-$HOME/.config}"',
|
|
652
|
-
'runs="$config_root/opencode/
|
|
786
|
+
'runs="$config_root/opencode/jobs/runs/$scope"',
|
|
653
787
|
'mkdir -p "$runs"',
|
|
654
788
|
'record_file="$runs/$slug.jsonl"',
|
|
655
789
|
...isTracked ? [
|
|
656
|
-
'sessions="$config_root/opencode/
|
|
790
|
+
'sessions="$config_root/opencode/jobs/sessions/$scope"',
|
|
657
791
|
'mkdir -p "$sessions"',
|
|
658
792
|
'state_file="$sessions/$slug.txt"',
|
|
659
793
|
`session_mode=${shQuote(mode)}`,
|
|
660
794
|
'prev_session=""',
|
|
661
795
|
'if [ -f "$state_file" ]; then prev_session=$(cat "$state_file"); fi'
|
|
662
796
|
] : [],
|
|
663
|
-
'started_by="${
|
|
797
|
+
'started_by="${OPENCODE_JOBS_STARTED_BY:-scheduled}"',
|
|
664
798
|
'run_id="$(date +%s%N)-$$"',
|
|
665
799
|
"started=$(date +%s)",
|
|
666
800
|
'new_session=""',
|
|
801
|
+
'wt_branch=""',
|
|
802
|
+
'wt_commit=""',
|
|
667
803
|
`export OPENCODE_PERMISSION='{"question":"deny"}'`,
|
|
668
|
-
'export
|
|
804
|
+
'export OPENCODE_JOBS_RUN_ID="$run_id"',
|
|
669
805
|
"finish() {",
|
|
670
806
|
' status="$1"',
|
|
671
807
|
' code="$2"',
|
|
672
808
|
" ended=$(date +%s)",
|
|
673
|
-
String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" >> "$record_file"`,
|
|
809
|
+
String.raw` printf '{"runId":"%s","slug":"%s","scopeId":"%s","startedAt":%s,"finishedAt":%s,"durationMs":%s,"status":"%s","exitCode":%s,"sessionId":"%s","startedBy":"%s","worktreeBranch":"%s","worktreeCommit":"%s"}\n' "$run_id" "$slug" "$scope" "$started" "$ended" "$((ended - started))" "$status" "$code" "$new_session" "$started_by" "$wt_branch" "$wt_commit" >> "$record_file"`,
|
|
674
810
|
"}",
|
|
675
811
|
"trap 'finish timeout 124; exit 124' TERM INT",
|
|
676
812
|
...job.guard === undefined ? [] : guardScriptLines(job.guard),
|
|
677
813
|
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"`,
|
|
814
|
+
...job.worktree === undefined ? [] : worktreePrologueLines(job, scopeId),
|
|
678
815
|
`oc_agent=${shQuote(job.run.agent ?? "")}`,
|
|
679
816
|
`oc_model=${shQuote(job.run.model ?? "")}`,
|
|
680
817
|
"prompt" in job.run ? "oc_command_mode=0" : "oc_command_mode=1",
|
|
@@ -705,7 +842,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
705
842
|
'cat "$json_out"',
|
|
706
843
|
...extractSessionIdLines("new_session"),
|
|
707
844
|
'if [ "$code" -ne 0 ] && [ -n "$prev_session" ] && [ -z "$new_session" ] && grep -qi "session not found" "$json_out"; then',
|
|
708
|
-
' echo "
|
|
845
|
+
' echo "opencode-jobs: session $prev_session not found, retrying with a fresh session"',
|
|
709
846
|
' rm -f "$json_out"',
|
|
710
847
|
' json_out="$(mktemp)"',
|
|
711
848
|
' run_opencode "" 1 >"$json_out" 2>&1',
|
|
@@ -754,6 +891,7 @@ function runScriptContent(job, scopeId, opencodeBin) {
|
|
|
754
891
|
"fi"
|
|
755
892
|
] : ['run_opencode "" 0', "code=$?"],
|
|
756
893
|
"trap - TERM INT",
|
|
894
|
+
...job.worktree === undefined ? [] : worktreeEpilogueLines(job.worktree),
|
|
757
895
|
'if [ "$code" -ne 0 ]; then finish failed "$code"; exit "$code"; fi',
|
|
758
896
|
...isCompact ? ['if [ -n "$new_session" ]; then compact_session "$new_session"; fi'] : [],
|
|
759
897
|
"finish success 0",
|
|
@@ -1020,9 +1158,14 @@ function scheduleJobOutput(input, directory) {
|
|
|
1020
1158
|
} catch (error) {
|
|
1021
1159
|
return fail(errorMessage(error));
|
|
1022
1160
|
}
|
|
1161
|
+
const hasWorktreeOptions = input.worktreeBase !== undefined || input.worktreeRef !== undefined || input.worktreeCommitMessage !== undefined;
|
|
1162
|
+
if (hasWorktreeOptions && input.worktree !== true) {
|
|
1163
|
+
return fail("set worktree: true to enable worktree options (worktreeBase, worktreeRef, worktreeCommitMessage)");
|
|
1164
|
+
}
|
|
1023
1165
|
let run;
|
|
1024
1166
|
let session;
|
|
1025
1167
|
let guard;
|
|
1168
|
+
let worktree;
|
|
1026
1169
|
let timeoutSeconds;
|
|
1027
1170
|
try {
|
|
1028
1171
|
run = validateRunSpec({
|
|
@@ -1034,6 +1177,17 @@ function scheduleJobOutput(input, directory) {
|
|
|
1034
1177
|
}, "job");
|
|
1035
1178
|
session = validateSession(input.session, "job");
|
|
1036
1179
|
guard = validateGuard(input.guard, "job");
|
|
1180
|
+
worktree = validateWorktree(input.worktree === true ? {
|
|
1181
|
+
...input.worktreeBase !== undefined && {
|
|
1182
|
+
base: input.worktreeBase
|
|
1183
|
+
},
|
|
1184
|
+
...input.worktreeRef !== undefined && {
|
|
1185
|
+
ref: input.worktreeRef
|
|
1186
|
+
},
|
|
1187
|
+
...input.worktreeCommitMessage !== undefined && {
|
|
1188
|
+
commitMessage: input.worktreeCommitMessage
|
|
1189
|
+
}
|
|
1190
|
+
} : undefined, "job");
|
|
1037
1191
|
timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
|
|
1038
1192
|
} catch (error) {
|
|
1039
1193
|
return fail(errorMessage(error));
|
|
@@ -1046,18 +1200,21 @@ function scheduleJobOutput(input, directory) {
|
|
|
1046
1200
|
run,
|
|
1047
1201
|
...session !== "new" && { session },
|
|
1048
1202
|
...guard !== undefined && { guard },
|
|
1203
|
+
...worktree !== undefined && { worktree },
|
|
1049
1204
|
...timeoutSeconds !== undefined && { timeoutSeconds },
|
|
1050
1205
|
createdAt: existing.ok ? existing.job.createdAt : nowIso(),
|
|
1051
1206
|
updatedAt: nowIso()
|
|
1052
1207
|
};
|
|
1053
1208
|
saveJob(directory, job);
|
|
1054
|
-
const relativePath = `.opencode/
|
|
1209
|
+
const relativePath = `.opencode/jobs/${slug}.json`;
|
|
1055
1210
|
const lines = [
|
|
1056
1211
|
`${existing.ok ? "Updated" : "Created"} job "${job.name}" (${slug})`,
|
|
1057
1212
|
`Definition: ${relativePath} (${job.schedule} \u2014 ${describeCron(sets)})`
|
|
1058
1213
|
];
|
|
1059
1214
|
if (session !== "new")
|
|
1060
1215
|
lines.push(`Session: ${session}`);
|
|
1216
|
+
if (worktree !== undefined)
|
|
1217
|
+
lines.push(`Worktree: yes (base ${worktree.base ?? "default"}, branch opencode-jobs/${slug}/<run>)`);
|
|
1061
1218
|
const entry = registryEntry(directory);
|
|
1062
1219
|
if (entry === undefined) {
|
|
1063
1220
|
lines.push("Project not enabled yet. Run enable_project to install the systemd timer.");
|
|
@@ -1105,11 +1262,15 @@ function showJobOutput(slugInput, directory) {
|
|
|
1105
1262
|
const lines = [
|
|
1106
1263
|
`${job.name} (${job.slug})`,
|
|
1107
1264
|
`Schedule: ${job.schedule} \u2014 ${describeCron(sets)}`,
|
|
1108
|
-
`Definition: .opencode/
|
|
1265
|
+
`Definition: .opencode/jobs/${job.slug}.json (updated ${job.updatedAt})`,
|
|
1109
1266
|
`Run: ${runDesc}`
|
|
1110
1267
|
];
|
|
1111
1268
|
if (job.guard !== undefined)
|
|
1112
1269
|
lines.push(`Guard: ${job.guard} (must exit 0 for the run to start)`);
|
|
1270
|
+
if (job.worktree !== undefined) {
|
|
1271
|
+
const base = job.worktree.base ?? worktreesDirectory(scopeId);
|
|
1272
|
+
lines.push(`Worktree: fresh per run at ${base} (from ${job.worktree.ref ?? "HEAD"}) \u2014 changes are committed to opencode-jobs/${job.slug}/\u2026 before the worktree is removed`);
|
|
1273
|
+
}
|
|
1113
1274
|
if (job.session !== undefined) {
|
|
1114
1275
|
const state = sessionStateFile(scopeId, job.slug);
|
|
1115
1276
|
const sessionId = existsSync4(state) ? readFileSync4(state, "utf8").trim() : "";
|
|
@@ -1151,9 +1312,7 @@ function removeJobDefinitionOutput(slugInput, directory) {
|
|
|
1151
1312
|
if (!existsSync4(file))
|
|
1152
1313
|
return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
|
|
1153
1314
|
rmSync2(file);
|
|
1154
|
-
const lines = [
|
|
1155
|
-
`Deleted job definition .opencode/scheduler/jobs/${slug}.json`
|
|
1156
|
-
];
|
|
1315
|
+
const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
|
|
1157
1316
|
const scopeId = registryEntry(directory)?.scopeId ?? deriveScopeId(directory);
|
|
1158
1317
|
const state = sessionStateFile(scopeId, slug);
|
|
1159
1318
|
if (existsSync4(state)) {
|
|
@@ -1201,7 +1360,7 @@ function runJobNowOutput(slugInput, directory) {
|
|
|
1201
1360
|
const fd = openSync(log, "a");
|
|
1202
1361
|
const child = spawn("/bin/sh", [script], {
|
|
1203
1362
|
cwd: path6.resolve(directory),
|
|
1204
|
-
env: { ...process.env,
|
|
1363
|
+
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1205
1364
|
stdio: ["ignore", fd, fd]
|
|
1206
1365
|
});
|
|
1207
1366
|
child.unref();
|
|
@@ -1235,7 +1394,7 @@ function listProjectsOutput() {
|
|
|
1235
1394
|
const entries = Object.values(registry.projects).toSorted((a, b) => a.workdir.localeCompare(b.workdir));
|
|
1236
1395
|
if (entries.length === 0)
|
|
1237
1396
|
return ok("No projects with scheduled jobs are registered.");
|
|
1238
|
-
const lines = ["Registry: ~/.config/opencode/
|
|
1397
|
+
const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
|
|
1239
1398
|
for (const entry of entries) {
|
|
1240
1399
|
const missing = existsSync4(entry.workdir) ? "" : " [WORKDIR MISSING]";
|
|
1241
1400
|
lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
|
|
@@ -1244,12 +1403,12 @@ function listProjectsOutput() {
|
|
|
1244
1403
|
`));
|
|
1245
1404
|
}
|
|
1246
1405
|
var listJobsTool = tool({
|
|
1247
|
-
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.",
|
|
1248
1407
|
args: {},
|
|
1249
1408
|
execute: (_input, context) => Promise.resolve(listJobsOutput(context.directory))
|
|
1250
1409
|
});
|
|
1251
1410
|
var scheduleJobTool = tool({
|
|
1252
|
-
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.",
|
|
1253
1412
|
args: {
|
|
1254
1413
|
name: tool.schema.string().describe("Human-readable job name"),
|
|
1255
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)'),
|
|
@@ -1258,6 +1417,10 @@ var scheduleJobTool = tool({
|
|
|
1258
1417
|
arguments: tool.schema.string().optional().describe("Arguments passed to the custom command"),
|
|
1259
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)`),
|
|
1260
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'),
|
|
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"),
|
|
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"),
|
|
1422
|
+
worktreeRef: tool.schema.string().optional().describe('Git ref the worktree branch starts from (default: "HEAD")'),
|
|
1423
|
+
worktreeCommitMessage: tool.schema.string().optional().describe('Commit message used when saving worktree changes (default: "opencode-jobs: <slug> run <runId>")'),
|
|
1261
1424
|
agent: tool.schema.string().optional().describe("Agent to use for the run"),
|
|
1262
1425
|
model: tool.schema.string().optional().describe("Model to use for the run"),
|
|
1263
1426
|
timeoutSeconds: tool.schema.number().optional().describe("Hard timeout in seconds (0 or omitted disables). systemd stops the run with SIGTERM after this"),
|
|
@@ -1295,7 +1458,7 @@ var jobLogsTool = tool({
|
|
|
1295
1458
|
execute: (input, context) => Promise.resolve(jobLogsOutput(input.slug, input.lines, context.directory))
|
|
1296
1459
|
});
|
|
1297
1460
|
var enableProjectTool = tool({
|
|
1298
|
-
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.",
|
|
1299
1462
|
args: {},
|
|
1300
1463
|
execute: (_input, context) => Promise.resolve(enableProjectOutput(context.directory))
|
|
1301
1464
|
});
|
|
@@ -1319,11 +1482,11 @@ function disableProjectOutput(directory) {
|
|
|
1319
1482
|
}
|
|
1320
1483
|
}
|
|
1321
1484
|
var listProjectsTool = tool({
|
|
1322
|
-
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).",
|
|
1323
1486
|
args: {},
|
|
1324
1487
|
execute: () => Promise.resolve(listProjectsOutput())
|
|
1325
1488
|
});
|
|
1326
|
-
var
|
|
1489
|
+
var jobsTools = {
|
|
1327
1490
|
schedule_job: scheduleJobTool,
|
|
1328
1491
|
list_jobs: listJobsTool,
|
|
1329
1492
|
get_job: showJobTool,
|
|
@@ -1335,12 +1498,102 @@ var schedulerTools = {
|
|
|
1335
1498
|
list_projects: listProjectsTool
|
|
1336
1499
|
};
|
|
1337
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
|
+
|
|
1338
1577
|
// src/index.ts
|
|
1339
|
-
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
|
+
}
|
|
1340
1593
|
if (process.platform !== "linux") {
|
|
1341
1594
|
console.error("[opencode-jobs] warning: this is not a Linux host, systemd user timers are unavailable \u2014 scheduling tools will not work here");
|
|
1342
1595
|
}
|
|
1343
|
-
return Promise.resolve({ tool:
|
|
1596
|
+
return Promise.resolve({ tool: jobsTools });
|
|
1344
1597
|
};
|
|
1345
1598
|
export {
|
|
1346
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;
|