@awebai/oats 0.22.9 → 0.22.12
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/bin/oats.mjs +150 -4
- package/docs/desktop.md +28 -2
- package/docs/release-notes/v0.22.10.md +25 -0
- package/docs/release-notes/v0.22.11.md +49 -0
- package/docs/release-notes/v0.22.12.md +53 -0
- package/docs/schedules.md +143 -0
- package/lib/schedule-host.mjs +150 -0
- package/lib/schedule.mjs +731 -0
- package/lib/servers.mjs +16 -0
- package/package.json +3 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** The ONE host timer that runs `oats schedule tick --host` once a minute:
|
|
2
|
+
* a launchd user agent on macOS, a systemd user timer on Linux. Nothing is
|
|
3
|
+
* installed unless `oats schedule host install` is run explicitly, and
|
|
4
|
+
* status reports what the OS says is loaded, never what a file implies. */
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { hostScheduleDir, scheduleError } from "./schedule.mjs";
|
|
11
|
+
|
|
12
|
+
export const LAUNCHD_LABEL = "ai.oats.schedule-tick";
|
|
13
|
+
export const SYSTEMD_UNIT = "oats-schedule-tick";
|
|
14
|
+
const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
|
|
15
|
+
|
|
16
|
+
function escapeXml(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
|
17
|
+
|
|
18
|
+
/** launchd and systemd start units with a short PATH that has no Homebrew
|
|
19
|
+
* tmux and no installed harness. The unit therefore carries an explicit
|
|
20
|
+
* PATH: the installer's own PATH (the operator's tool environment) plus
|
|
21
|
+
* the node running the installer and the usual prefixes. */
|
|
22
|
+
export function hostPath({ node = process.execPath, envPath = process.env.PATH || "" } = {}) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const p of [dirname(node), ...envPath.split(":"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
|
|
26
|
+
if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
27
|
+
}
|
|
28
|
+
return out.join(":");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderLaunchdPlist({ node = process.execPath, oatsBin = OATS_BIN, logDir = join(hostScheduleDir(), "log"), intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
|
|
32
|
+
const env = `\n <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key><string>${escapeXml(path)}</string>${oatsHomeDir ? `\n <key>OATS_HOME_DIR</key><string>${escapeXml(oatsHomeDir)}</string>` : ""}\n </dict>`;
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>
|
|
40
|
+
<string>${escapeXml(node)}</string>
|
|
41
|
+
<string>${escapeXml(oatsBin)}</string>
|
|
42
|
+
<string>schedule</string>
|
|
43
|
+
<string>tick</string>
|
|
44
|
+
<string>--host</string>
|
|
45
|
+
<string>--json</string>
|
|
46
|
+
</array>
|
|
47
|
+
<key>StartInterval</key><integer>${intervalSec}</integer>
|
|
48
|
+
<key>RunAtLoad</key><false/>
|
|
49
|
+
<key>ProcessType</key><string>Background</string>
|
|
50
|
+
<key>StandardOutPath</key><string>${escapeXml(join(logDir, "tick.log"))}</string>
|
|
51
|
+
<key>StandardErrorPath</key><string>${escapeXml(join(logDir, "tick.err"))}</string>${env}
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
/** systemd unit values: `%` is a specifier escape (`%%`), and a quoted
|
|
57
|
+
* string protects spaces; backslashes and double quotes are escaped. */
|
|
58
|
+
export function systemdQuote(value) {
|
|
59
|
+
return `"${String(value).replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
60
|
+
}
|
|
61
|
+
export function renderSystemdUnits({ node = process.execPath, oatsBin = OATS_BIN, intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
|
|
62
|
+
const env = `Environment=${systemdQuote(`PATH=${path}`)}\n${oatsHomeDir ? `Environment=${systemdQuote(`OATS_HOME_DIR=${oatsHomeDir}`)}\n` : ""}`;
|
|
63
|
+
return {
|
|
64
|
+
service: `[Unit]\nDescription=OATS schedule tick\n\n[Service]\nType=oneshot\n${env}ExecStart=${systemdQuote(node)} ${systemdQuote(oatsBin)} schedule tick --host --json\n`,
|
|
65
|
+
timer: `[Unit]\nDescription=OATS schedule tick every ${intervalSec}s\n\n[Timer]\nOnBootSec=${intervalSec}\nOnUnitActiveSec=${intervalSec}\nAccuracySec=5\nUnit=${SYSTEMD_UNIT}.service\n\n[Install]\nWantedBy=timers.target\n`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `unitDir` overrides the OS location (tests never touch a live timer's files). */
|
|
70
|
+
export function hostUnitPaths(os = platform(), { unitDir } = {}) {
|
|
71
|
+
if (os === "darwin") return { kind: "launchd", plist: join(unitDir || join(homedir(), "Library", "LaunchAgents"), `${LAUNCHD_LABEL}.plist`) };
|
|
72
|
+
if (os === "linux") { const dir = unitDir || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user"); return { kind: "systemd", service: join(dir, `${SYSTEMD_UNIT}.service`), timer: join(dir, `${SYSTEMD_UNIT}.timer`) }; }
|
|
73
|
+
return { kind: "unsupported" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function run(exec, argv) {
|
|
77
|
+
try { return { ok: true, out: (exec || execFileSync)(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }) }; }
|
|
78
|
+
catch (e) { return { ok: false, out: String(e.stdout || ""), err: String(e.stderr || e.message || "").trim() }; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** installed = the unit file exists; active = the OS reports it loaded/active. */
|
|
82
|
+
export function hostUnitStatus({ exec, os = platform(), unitDir } = {}) {
|
|
83
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
84
|
+
if (p.kind === "launchd") {
|
|
85
|
+
const installed = existsSync(p.plist);
|
|
86
|
+
const r = run(exec, ["launchctl", "print", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
|
|
87
|
+
return { kind: p.kind, unit: p.plist, installed, active: r.ok };
|
|
88
|
+
}
|
|
89
|
+
if (p.kind === "systemd") {
|
|
90
|
+
const installed = existsSync(p.timer) && existsSync(p.service);
|
|
91
|
+
const r = run(exec, ["systemctl", "--user", "is-active", `${SYSTEMD_UNIT}.timer`]);
|
|
92
|
+
return { kind: p.kind, unit: p.timer, installed, active: r.ok && r.out.trim() === "active" };
|
|
93
|
+
}
|
|
94
|
+
return { kind: "unsupported", installed: false, active: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function installHostUnit({ exec, os = platform(), intervalSec = 60, unitDir } = {}) {
|
|
98
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
99
|
+
mkdirSync(join(hostScheduleDir(), "log"), { recursive: true });
|
|
100
|
+
if (p.kind === "launchd") {
|
|
101
|
+
mkdirSync(dirname(p.plist), { recursive: true });
|
|
102
|
+
const status = hostUnitStatus({ exec, os, unitDir });
|
|
103
|
+
const rendered = renderLaunchdPlist({ intervalSec });
|
|
104
|
+
// Idempotent: registering another scope while the same timer is active
|
|
105
|
+
// must not unload an in-flight tick just to rewrite identical bytes.
|
|
106
|
+
if (status.active && existsSync(p.plist) && readFileSync(p.plist, "utf8") === rendered) return status;
|
|
107
|
+
if (status.active) { const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout failed before reinstall: ${r.err}`); }
|
|
108
|
+
writeFileSync(p.plist, rendered);
|
|
109
|
+
const r = run(exec, ["launchctl", "bootstrap", `gui/${userInfo().uid}`, p.plist]);
|
|
110
|
+
if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootstrap failed: ${r.err}`);
|
|
111
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
112
|
+
}
|
|
113
|
+
if (p.kind === "systemd") {
|
|
114
|
+
mkdirSync(dirname(p.timer), { recursive: true });
|
|
115
|
+
const units = renderSystemdUnits({ intervalSec });
|
|
116
|
+
const status = hostUnitStatus({ exec, os, unitDir });
|
|
117
|
+
if (status.active && existsSync(p.service) && existsSync(p.timer) && readFileSync(p.service, "utf8") === units.service && readFileSync(p.timer, "utf8") === units.timer) return status;
|
|
118
|
+
writeFileSync(p.service, units.service); writeFileSync(p.timer, units.timer);
|
|
119
|
+
for (const argv of [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", `${SYSTEMD_UNIT}.timer`]]) { const r = run(exec, argv); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `${argv.join(" ")} failed: ${r.err}`); }
|
|
120
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
121
|
+
}
|
|
122
|
+
throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Uninstall reports the OS's answer: a unit that stays loaded after the
|
|
126
|
+
* OS refused to unload it is an error, not a removed file. */
|
|
127
|
+
export function uninstallHostUnit({ exec, os = platform(), unitDir } = {}) {
|
|
128
|
+
const p = hostUnitPaths(os, { unitDir });
|
|
129
|
+
if (p.kind === "launchd") {
|
|
130
|
+
const before = hostUnitStatus({ exec, os, unitDir });
|
|
131
|
+
if (before.active) {
|
|
132
|
+
const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
|
|
133
|
+
if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout did not unload ${LAUNCHD_LABEL}: ${r.err || "still loaded"}; the unit file is kept`);
|
|
134
|
+
}
|
|
135
|
+
rmSync(p.plist, { force: true });
|
|
136
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
137
|
+
}
|
|
138
|
+
if (p.kind === "systemd") {
|
|
139
|
+
const before = hostUnitStatus({ exec, os, unitDir });
|
|
140
|
+
if (before.active) {
|
|
141
|
+
const r = run(exec, ["systemctl", "--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`]);
|
|
142
|
+
if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user disable --now ${SYSTEMD_UNIT}.timer failed: ${r.err || "still active"}; the unit files are kept`);
|
|
143
|
+
}
|
|
144
|
+
rmSync(p.timer, { force: true }); rmSync(p.service, { force: true });
|
|
145
|
+
const r = run(exec, ["systemctl", "--user", "daemon-reload"]);
|
|
146
|
+
if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user daemon-reload failed: ${r.err}`);
|
|
147
|
+
return hostUnitStatus({ exec, os, unitDir });
|
|
148
|
+
}
|
|
149
|
+
throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
|
|
150
|
+
}
|