@staticduo/opencode-scheduler 1.3.2 → 1.3.4
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 +1 -1
- package/dist/index.js +200 -52
- package/dist/systemd.d.ts +17 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Jobs run from the working directory where you created them, picking up your `ope
|
|
|
96
96
|
- **No overlap**: if the previous run is still active, the next scheduled tick is skipped.
|
|
97
97
|
- **Non-interactive by default**: scheduled runs force `OPENCODE_PERMISSION` to deny "question" prompts, so jobs don't hang waiting for approvals.
|
|
98
98
|
- **Optional timeout**: set `timeoutSeconds` to hard-stop long runs (SIGTERM, then SIGKILL).
|
|
99
|
-
- **Transactional systemd updates**: Linux
|
|
99
|
+
- **Transactional systemd updates**: Linux installation preserves regular files or exact symlink targets (including masked units), permissions, unit-file state, and active state on failure. A bounded cross-process lock serializes updates to the same scoped timer; live locks are never broken and dead stale locks are reclaimed.
|
|
100
100
|
|
|
101
101
|
### Platform Support
|
|
102
102
|
|
package/dist/index.js
CHANGED
|
@@ -12335,7 +12335,7 @@ function tool(input) {
|
|
|
12335
12335
|
}
|
|
12336
12336
|
tool.schema = exports_external;
|
|
12337
12337
|
// src/index.ts
|
|
12338
|
-
import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
12338
|
+
import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
12339
12339
|
import { basename, dirname, join as join2, resolve as resolvePath } from "path";
|
|
12340
12340
|
import { homedir, platform } from "os";
|
|
12341
12341
|
import { execFileSync, execSync, spawn } from "child_process";
|
|
@@ -12435,10 +12435,14 @@ function cronToSystemdCalendars(cron) {
|
|
|
12435
12435
|
import {
|
|
12436
12436
|
chmodSync,
|
|
12437
12437
|
existsSync,
|
|
12438
|
+
lstatSync,
|
|
12438
12439
|
mkdirSync,
|
|
12439
12440
|
readFileSync,
|
|
12441
|
+
readlinkSync,
|
|
12440
12442
|
renameSync,
|
|
12443
|
+
rmSync,
|
|
12441
12444
|
statSync,
|
|
12445
|
+
symlinkSync,
|
|
12442
12446
|
unlinkSync,
|
|
12443
12447
|
writeFileSync
|
|
12444
12448
|
} from "fs";
|
|
@@ -12467,36 +12471,83 @@ function withSystemdRuntimeEnv(env, dependencies = defaultRuntimeEnvDependencies
|
|
|
12467
12471
|
var defaultFileSystem = {
|
|
12468
12472
|
chmod: chmodSync,
|
|
12469
12473
|
exists: existsSync,
|
|
12474
|
+
lstat: lstatSync,
|
|
12470
12475
|
mkdir: mkdirSync,
|
|
12471
12476
|
readFile: readFileSync,
|
|
12477
|
+
readlink: readlinkSync,
|
|
12472
12478
|
rename: renameSync,
|
|
12479
|
+
rm: rmSync,
|
|
12473
12480
|
stat: statSync,
|
|
12481
|
+
symlink: symlinkSync,
|
|
12474
12482
|
unlink: unlinkSync,
|
|
12475
12483
|
writeFile: writeFileSync
|
|
12476
12484
|
};
|
|
12485
|
+
var sleepArray = new Int32Array(new SharedArrayBuffer(4));
|
|
12486
|
+
var defaultLockOptions = {
|
|
12487
|
+
timeoutMs: 1e4,
|
|
12488
|
+
staleAfterMs: 60000,
|
|
12489
|
+
pollMs: 25,
|
|
12490
|
+
now: Date.now,
|
|
12491
|
+
pid: process.pid,
|
|
12492
|
+
isPidAlive(pid) {
|
|
12493
|
+
try {
|
|
12494
|
+
process.kill(pid, 0);
|
|
12495
|
+
return true;
|
|
12496
|
+
} catch {
|
|
12497
|
+
return false;
|
|
12498
|
+
}
|
|
12499
|
+
},
|
|
12500
|
+
sleep(milliseconds) {
|
|
12501
|
+
Atomics.wait(sleepArray, 0, 0, milliseconds);
|
|
12502
|
+
}
|
|
12503
|
+
};
|
|
12477
12504
|
function snapshotFile(path, fileSystem) {
|
|
12478
|
-
|
|
12479
|
-
|
|
12480
|
-
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
}
|
|
12505
|
+
let stats;
|
|
12506
|
+
try {
|
|
12507
|
+
stats = fileSystem.lstat(path);
|
|
12508
|
+
} catch (error45) {
|
|
12509
|
+
if (isErrorCode(error45, "ENOENT"))
|
|
12510
|
+
return { path, type: "missing" };
|
|
12511
|
+
throw error45;
|
|
12512
|
+
}
|
|
12513
|
+
if (stats.isSymbolicLink())
|
|
12514
|
+
return { path, type: "symlink", target: fileSystem.readlink(path) };
|
|
12515
|
+
if (stats.isFile()) {
|
|
12516
|
+
return { path, type: "regular", content: fileSystem.readFile(path), mode: stats.mode & 511 };
|
|
12517
|
+
}
|
|
12518
|
+
throw new Error(`Unsupported systemd unit node type at ${path}; refusing to mutate it`);
|
|
12486
12519
|
}
|
|
12487
12520
|
var temporaryFileSequence = 0;
|
|
12488
|
-
function
|
|
12521
|
+
function temporaryPath(path) {
|
|
12489
12522
|
temporaryFileSequence += 1;
|
|
12490
|
-
|
|
12523
|
+
return `${path}.tmp-${process.pid}-${temporaryFileSequence}`;
|
|
12524
|
+
}
|
|
12525
|
+
function atomicReplace(path, content, mode, fileSystem) {
|
|
12526
|
+
const temporary = temporaryPath(path);
|
|
12491
12527
|
try {
|
|
12492
|
-
fileSystem.writeFile(
|
|
12493
|
-
fileSystem.chmod(
|
|
12494
|
-
fileSystem.rename(
|
|
12528
|
+
fileSystem.writeFile(temporary, content, { mode });
|
|
12529
|
+
fileSystem.chmod(temporary, mode);
|
|
12530
|
+
fileSystem.rename(temporary, path);
|
|
12495
12531
|
fileSystem.chmod(path, mode);
|
|
12496
12532
|
} finally {
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12533
|
+
removeNode(temporary, fileSystem);
|
|
12534
|
+
}
|
|
12535
|
+
}
|
|
12536
|
+
function atomicSymlink(path, target, fileSystem) {
|
|
12537
|
+
const temporary = temporaryPath(path);
|
|
12538
|
+
try {
|
|
12539
|
+
fileSystem.symlink(target, temporary);
|
|
12540
|
+
fileSystem.rename(temporary, path);
|
|
12541
|
+
} finally {
|
|
12542
|
+
removeNode(temporary, fileSystem);
|
|
12543
|
+
}
|
|
12544
|
+
}
|
|
12545
|
+
function removeNode(path, fileSystem) {
|
|
12546
|
+
try {
|
|
12547
|
+
fileSystem.unlink(path);
|
|
12548
|
+
} catch (error45) {
|
|
12549
|
+
if (!isErrorCode(error45, "ENOENT"))
|
|
12550
|
+
throw error45;
|
|
12500
12551
|
}
|
|
12501
12552
|
}
|
|
12502
12553
|
function commandOutput(value) {
|
|
@@ -12513,57 +12564,153 @@ function commandOutput(value) {
|
|
|
12513
12564
|
}
|
|
12514
12565
|
return "";
|
|
12515
12566
|
}
|
|
12516
|
-
function
|
|
12567
|
+
function queryUnitFileState(run, timerUnit) {
|
|
12568
|
+
let output = "";
|
|
12569
|
+
try {
|
|
12570
|
+
output = commandOutput(run(`systemctl --user is-enabled ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
|
|
12571
|
+
} catch (error45) {
|
|
12572
|
+
output = commandOutput(error45);
|
|
12573
|
+
}
|
|
12574
|
+
const supported = [
|
|
12575
|
+
"enabled",
|
|
12576
|
+
"enabled-runtime",
|
|
12577
|
+
"disabled",
|
|
12578
|
+
"static",
|
|
12579
|
+
"indirect",
|
|
12580
|
+
"masked",
|
|
12581
|
+
"masked-runtime",
|
|
12582
|
+
"linked",
|
|
12583
|
+
"linked-runtime",
|
|
12584
|
+
"alias",
|
|
12585
|
+
"not-found"
|
|
12586
|
+
];
|
|
12587
|
+
if (supported.includes(output))
|
|
12588
|
+
return output;
|
|
12589
|
+
const unrecoverable = ["generated", "transient", "bad"];
|
|
12590
|
+
if (unrecoverable.includes(output)) {
|
|
12591
|
+
throw new Error(`Cannot safely restore ${timerUnit} from systemd unit-file state ${output}; refusing to mutate it`);
|
|
12592
|
+
}
|
|
12593
|
+
throw new Error(`Unable to determine ${timerUnit} unit-file state: ${output || "no status returned"}`);
|
|
12594
|
+
}
|
|
12595
|
+
function queryActiveState(run, timerUnit) {
|
|
12517
12596
|
let output = "";
|
|
12518
12597
|
try {
|
|
12519
|
-
output = commandOutput(run(`systemctl --user
|
|
12598
|
+
output = commandOutput(run(`systemctl --user is-active ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
|
|
12520
12599
|
} catch (error45) {
|
|
12521
12600
|
output = commandOutput(error45);
|
|
12522
12601
|
}
|
|
12523
|
-
|
|
12602
|
+
if (["active", "activating", "reloading"].includes(output))
|
|
12603
|
+
return true;
|
|
12604
|
+
if (["inactive", "failed", "deactivating", "unknown"].includes(output))
|
|
12605
|
+
return false;
|
|
12606
|
+
throw new Error(`Unable to determine whether ${timerUnit} is active: ${output || "no status returned"}`);
|
|
12524
12607
|
}
|
|
12525
12608
|
function restoreFile(snapshot, fileSystem) {
|
|
12526
|
-
if (
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12609
|
+
if (snapshot.type === "missing") {
|
|
12610
|
+
removeNode(snapshot.path, fileSystem);
|
|
12611
|
+
} else if (snapshot.type === "symlink") {
|
|
12612
|
+
atomicSymlink(snapshot.path, snapshot.target, fileSystem);
|
|
12613
|
+
} else {
|
|
12614
|
+
atomicReplace(snapshot.path, snapshot.content, snapshot.mode, fileSystem);
|
|
12531
12615
|
}
|
|
12532
|
-
atomicReplace(snapshot.path, snapshot.content ?? Buffer.alloc(0), snapshot.mode ?? 420, fileSystem);
|
|
12533
12616
|
}
|
|
12534
12617
|
function bestEffort(action) {
|
|
12535
12618
|
try {
|
|
12536
12619
|
action();
|
|
12537
12620
|
} catch {}
|
|
12538
12621
|
}
|
|
12622
|
+
function restoreUnitFileState(run, timerUnit, state) {
|
|
12623
|
+
if (state === "enabled")
|
|
12624
|
+
run(`systemctl --user enable ${timerUnit}`, { stdio: "ignore" });
|
|
12625
|
+
if (state === "enabled-runtime")
|
|
12626
|
+
run(`systemctl --user enable --runtime ${timerUnit}`, { stdio: "ignore" });
|
|
12627
|
+
if (state === "disabled")
|
|
12628
|
+
run(`systemctl --user disable ${timerUnit}`, { stdio: "ignore" });
|
|
12629
|
+
}
|
|
12630
|
+
function isErrorCode(error45, code) {
|
|
12631
|
+
return typeof error45 === "object" && error45 !== null && "code" in error45 && error45.code === code;
|
|
12632
|
+
}
|
|
12633
|
+
function readLockMetadata(lockPath, fileSystem) {
|
|
12634
|
+
try {
|
|
12635
|
+
const parsed = JSON.parse(fileSystem.readFile(join(lockPath, "owner.json"), "utf8"));
|
|
12636
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
12637
|
+
return {};
|
|
12638
|
+
const record2 = parsed;
|
|
12639
|
+
return {
|
|
12640
|
+
pid: typeof record2.pid === "number" ? record2.pid : undefined,
|
|
12641
|
+
timestamp: typeof record2.timestamp === "number" ? record2.timestamp : undefined
|
|
12642
|
+
};
|
|
12643
|
+
} catch {
|
|
12644
|
+
return {};
|
|
12645
|
+
}
|
|
12646
|
+
}
|
|
12647
|
+
function acquireLock(lockRoot, timerUnit, fileSystem, overrides) {
|
|
12648
|
+
const options = { ...defaultLockOptions, ...overrides };
|
|
12649
|
+
fileSystem.mkdir(lockRoot, { recursive: true });
|
|
12650
|
+
const lockPath = join(lockRoot, `${timerUnit}.lock`);
|
|
12651
|
+
const startedAt = options.now();
|
|
12652
|
+
while (true) {
|
|
12653
|
+
try {
|
|
12654
|
+
fileSystem.mkdir(lockPath);
|
|
12655
|
+
try {
|
|
12656
|
+
fileSystem.writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: options.pid, timestamp: options.now() }));
|
|
12657
|
+
} catch (error45) {
|
|
12658
|
+
fileSystem.rm(lockPath, { recursive: true, force: true });
|
|
12659
|
+
throw error45;
|
|
12660
|
+
}
|
|
12661
|
+
return () => fileSystem.rm(lockPath, { recursive: true, force: true });
|
|
12662
|
+
} catch (error45) {
|
|
12663
|
+
if (!isErrorCode(error45, "EEXIST"))
|
|
12664
|
+
throw error45;
|
|
12665
|
+
const metadata = readLockMetadata(lockPath, fileSystem);
|
|
12666
|
+
const timestamp = metadata.timestamp ?? fileSystem.lstat(lockPath).mtimeMs;
|
|
12667
|
+
const oldEnough = options.now() - timestamp >= options.staleAfterMs;
|
|
12668
|
+
const ownerAlive = metadata.pid !== undefined && options.isPidAlive(metadata.pid);
|
|
12669
|
+
if (oldEnough && !ownerAlive) {
|
|
12670
|
+
fileSystem.rm(lockPath, { recursive: true, force: true });
|
|
12671
|
+
continue;
|
|
12672
|
+
}
|
|
12673
|
+
if (options.now() - startedAt >= options.timeoutMs) {
|
|
12674
|
+
throw new Error(`Timed out waiting ${options.timeoutMs}ms for systemd install lock ${lockPath}`);
|
|
12675
|
+
}
|
|
12676
|
+
options.sleep(options.pollMs);
|
|
12677
|
+
}
|
|
12678
|
+
}
|
|
12679
|
+
}
|
|
12539
12680
|
function installSystemdUnits(request) {
|
|
12540
12681
|
const fileSystem = request.fileSystem ?? defaultFileSystem;
|
|
12541
|
-
|
|
12542
|
-
const servicePath = join(request.unitDir, request.serviceUnit);
|
|
12543
|
-
const timerPath = join(request.unitDir, request.timerUnit);
|
|
12544
|
-
const serviceSnapshot = snapshotFile(servicePath, fileSystem);
|
|
12545
|
-
const timerSnapshot = snapshotFile(timerPath, fileSystem);
|
|
12546
|
-
const wasEnabled = queryTimerState(request.run, request.timerUnit, "is-enabled");
|
|
12547
|
-
const wasActive = queryTimerState(request.run, request.timerUnit, "is-active");
|
|
12682
|
+
const releaseLock = acquireLock(request.lockDir ?? join(request.unitDir, ".opencode-scheduler-locks"), request.timerUnit, fileSystem, request.lock);
|
|
12548
12683
|
try {
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
request.
|
|
12552
|
-
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
|
|
12558
|
-
|
|
12559
|
-
|
|
12560
|
-
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12565
|
-
|
|
12566
|
-
|
|
12684
|
+
fileSystem.mkdir(request.unitDir, { recursive: true });
|
|
12685
|
+
const servicePath = join(request.unitDir, request.serviceUnit);
|
|
12686
|
+
const timerPath = join(request.unitDir, request.timerUnit);
|
|
12687
|
+
const serviceSnapshot = snapshotFile(servicePath, fileSystem);
|
|
12688
|
+
const timerSnapshot = snapshotFile(timerPath, fileSystem);
|
|
12689
|
+
const unitFileState = queryUnitFileState(request.run, request.timerUnit);
|
|
12690
|
+
const wasActive = queryActiveState(request.run, request.timerUnit);
|
|
12691
|
+
let enabledByAttempt = false;
|
|
12692
|
+
try {
|
|
12693
|
+
atomicReplace(servicePath, request.serviceContent, 420, fileSystem);
|
|
12694
|
+
atomicReplace(timerPath, request.timerContent, 420, fileSystem);
|
|
12695
|
+
request.run("systemctl --user daemon-reload");
|
|
12696
|
+
request.run(`systemctl --user enable ${request.timerUnit}`);
|
|
12697
|
+
enabledByAttempt = true;
|
|
12698
|
+
request.run(`systemctl --user start ${request.timerUnit}`);
|
|
12699
|
+
} catch (error45) {
|
|
12700
|
+
if (!wasActive)
|
|
12701
|
+
bestEffort(() => request.run(`systemctl --user stop ${request.timerUnit}`, { stdio: "ignore" }));
|
|
12702
|
+
if (enabledByAttempt)
|
|
12703
|
+
bestEffort(() => request.run(`systemctl --user disable ${request.timerUnit}`, { stdio: "ignore" }));
|
|
12704
|
+
bestEffort(() => restoreFile(serviceSnapshot, fileSystem));
|
|
12705
|
+
bestEffort(() => restoreFile(timerSnapshot, fileSystem));
|
|
12706
|
+
bestEffort(() => request.run("systemctl --user daemon-reload", { stdio: "ignore" }));
|
|
12707
|
+
bestEffort(() => restoreUnitFileState(request.run, request.timerUnit, unitFileState));
|
|
12708
|
+
if (wasActive)
|
|
12709
|
+
bestEffort(() => request.run(`systemctl --user start ${request.timerUnit}`, { stdio: "ignore" }));
|
|
12710
|
+
throw error45;
|
|
12711
|
+
}
|
|
12712
|
+
} finally {
|
|
12713
|
+
releaseLock();
|
|
12567
12714
|
}
|
|
12568
12715
|
}
|
|
12569
12716
|
|
|
@@ -13424,6 +13571,7 @@ function installSystemdJob(job, run = defaultSystemdCommandRunner) {
|
|
|
13424
13571
|
const timerUnit = timerPath.slice(SYSTEMD_USER_DIR.length + 1);
|
|
13425
13572
|
installSystemdUnits({
|
|
13426
13573
|
unitDir: SYSTEMD_USER_DIR,
|
|
13574
|
+
lockDir: join2(SCHEDULER_DIR, "systemd-install-locks"),
|
|
13427
13575
|
serviceUnit,
|
|
13428
13576
|
timerUnit,
|
|
13429
13577
|
serviceContent: createSystemdService(job),
|
|
@@ -13811,7 +13959,7 @@ function removePaths(paths, errors3) {
|
|
|
13811
13959
|
if (!existsSync2(path))
|
|
13812
13960
|
continue;
|
|
13813
13961
|
try {
|
|
13814
|
-
|
|
13962
|
+
rmSync2(path, { recursive: true, force: true });
|
|
13815
13963
|
removed.push(path);
|
|
13816
13964
|
} catch (error45) {
|
|
13817
13965
|
const msg = error45 instanceof Error ? error45.message : String(error45);
|
package/dist/systemd.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
|
|
2
2
|
import type { ExecSyncOptions } from "child_process";
|
|
3
3
|
export type SystemdCommandRunner = (command: string, options?: ExecSyncOptions) => Buffer | string;
|
|
4
4
|
export interface RuntimeEnvDependencies {
|
|
@@ -8,21 +8,37 @@ export interface RuntimeEnvDependencies {
|
|
|
8
8
|
export declare function withSystemdRuntimeEnv(env: NodeJS.ProcessEnv, dependencies?: RuntimeEnvDependencies): NodeJS.ProcessEnv;
|
|
9
9
|
export interface SystemdInstallRequest {
|
|
10
10
|
unitDir: string;
|
|
11
|
+
lockDir?: string;
|
|
11
12
|
serviceUnit: string;
|
|
12
13
|
timerUnit: string;
|
|
13
14
|
serviceContent: string;
|
|
14
15
|
timerContent: string;
|
|
15
16
|
run: SystemdCommandRunner;
|
|
16
17
|
fileSystem?: SystemdFileSystem;
|
|
18
|
+
lock?: Partial<SystemdLockOptions>;
|
|
17
19
|
}
|
|
18
20
|
export interface SystemdFileSystem {
|
|
19
21
|
chmod: typeof chmodSync;
|
|
20
22
|
exists: typeof existsSync;
|
|
23
|
+
lstat: typeof lstatSync;
|
|
21
24
|
mkdir: typeof mkdirSync;
|
|
22
25
|
readFile: typeof readFileSync;
|
|
26
|
+
readlink: typeof readlinkSync;
|
|
23
27
|
rename: typeof renameSync;
|
|
28
|
+
rm: typeof rmSync;
|
|
24
29
|
stat: typeof statSync;
|
|
30
|
+
symlink: typeof symlinkSync;
|
|
25
31
|
unlink: typeof unlinkSync;
|
|
26
32
|
writeFile: typeof writeFileSync;
|
|
27
33
|
}
|
|
34
|
+
interface SystemdLockOptions {
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
staleAfterMs: number;
|
|
37
|
+
pollMs: number;
|
|
38
|
+
now: () => number;
|
|
39
|
+
pid: number;
|
|
40
|
+
isPidAlive: (pid: number) => boolean;
|
|
41
|
+
sleep: (milliseconds: number) => void;
|
|
42
|
+
}
|
|
28
43
|
export declare function installSystemdUnits(request: SystemdInstallRequest): void;
|
|
44
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staticduo/opencode-scheduler",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "OpenCode plugin for scheduling recurring jobs using launchd, systemd, Task Scheduler, or cron fallback",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|