@tea-agent/loop-agent 0.28.11 → 0.28.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.
@@ -0,0 +1,224 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { deriveClockUnitName } from "../clock.js";
6
+ import { MANAGED_MARKER } from "./types.js";
7
+ function userUnitDir() {
8
+ return path.join(os.homedir(), ".config", "systemd", "user");
9
+ }
10
+ function servicePath(unitName) {
11
+ return path.join(userUnitDir(), `${unitName}.service`);
12
+ }
13
+ function timerPath(unitName) {
14
+ return path.join(userUnitDir(), `${unitName}.timer`);
15
+ }
16
+ export function renderSystemdService(input) {
17
+ // Absolute argv — no shell.
18
+ const execStart = [
19
+ input.nodeBin,
20
+ input.agentWorkerEntry,
21
+ "scheduler",
22
+ "tick",
23
+ "--repo",
24
+ input.controlRepoRoot,
25
+ "--loop-agent-bin",
26
+ input.loopAgentBin,
27
+ "--clock-source",
28
+ "systemd-user",
29
+ "--json",
30
+ ]
31
+ .map(escapeSystemdArg)
32
+ .join(" ");
33
+ return `# ${MANAGED_MARKER} ${input.managedConfigHash}
34
+ [Unit]
35
+ Description=loop-agent Night Scheduler clock tick (${input.unitName})
36
+ ConditionPathIsDirectory=${escapeSystemdArg(input.controlRepoRoot)}
37
+
38
+ [Service]
39
+ Type=oneshot
40
+ WorkingDirectory=${escapeSystemdArg(input.controlRepoRoot)}
41
+ ExecStart=${execStart}
42
+ `;
43
+ }
44
+ export function renderSystemdTimer(input) {
45
+ return `# ${MANAGED_MARKER} ${input.managedConfigHash}
46
+ [Unit]
47
+ Description=loop-agent Night Scheduler clock timer (${input.unitName})
48
+
49
+ [Timer]
50
+ OnBootSec=30
51
+ OnUnitActiveSec=${input.intervalSec}
52
+ AccuracySec=10
53
+ Unit=${input.unitName}.service
54
+ Persistent=true
55
+
56
+ [Install]
57
+ WantedBy=timers.target
58
+ `;
59
+ }
60
+ function escapeSystemdArg(value) {
61
+ // Quote if needed; keep absolute paths intact.
62
+ if (/^[A-Za-z0-9_./:@%+=-]+$/.test(value))
63
+ return value;
64
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
65
+ }
66
+ async function runSystemctl(args) {
67
+ return new Promise((resolve) => {
68
+ const child = spawn("systemctl", ["--user", ...args], {
69
+ stdio: ["ignore", "pipe", "pipe"],
70
+ env: process.env,
71
+ });
72
+ let stdout = "";
73
+ let stderr = "";
74
+ child.stdout.on("data", (chunk) => {
75
+ stdout += String(chunk);
76
+ });
77
+ child.stderr.on("data", (chunk) => {
78
+ stderr += String(chunk);
79
+ });
80
+ child.on("close", (code) => {
81
+ resolve({ code: code ?? 1, stdout, stderr });
82
+ });
83
+ child.on("error", (error) => {
84
+ resolve({
85
+ code: 1,
86
+ stdout,
87
+ stderr: error instanceof Error ? error.message : String(error),
88
+ });
89
+ });
90
+ });
91
+ }
92
+ export const linuxSystemdUserAdapter = {
93
+ provider: "systemd-user",
94
+ async isAvailable() {
95
+ if (process.platform !== "linux")
96
+ return false;
97
+ const probe = await runSystemctl(["show-environment"]);
98
+ return probe.code === 0;
99
+ },
100
+ unitName(controlRepoRoot) {
101
+ // systemd unit names prefer simple chars; reuse hash-derived label.
102
+ return deriveClockUnitName(controlRepoRoot).replace(/^com\.tea-agent\./, "loop-agent-");
103
+ },
104
+ renderConfig(input) {
105
+ return [
106
+ renderSystemdService({
107
+ unitName: input.receipt.unitName,
108
+ nodeBin: input.entrypoints.nodeBin,
109
+ agentWorkerEntry: input.entrypoints.agentWorkerEntry,
110
+ controlRepoRoot: input.entrypoints.controlRepoRoot,
111
+ loopAgentBin: input.entrypoints.loopAgentBin,
112
+ managedConfigHash: input.receipt.managedConfigHash,
113
+ }),
114
+ renderSystemdTimer({
115
+ unitName: input.receipt.unitName,
116
+ intervalSec: input.receipt.intervalSec,
117
+ managedConfigHash: input.receipt.managedConfigHash,
118
+ }),
119
+ ].join("\n---\n");
120
+ },
121
+ async install(input) {
122
+ const svc = servicePath(input.receipt.unitName);
123
+ const tmr = timerPath(input.receipt.unitName);
124
+ await mkdir(userUnitDir(), { recursive: true });
125
+ for (const file of [svc, tmr]) {
126
+ try {
127
+ const existing = await readFile(file, "utf-8");
128
+ if (!existing.includes(MANAGED_MARKER)) {
129
+ throw new Error(`Refusing to overwrite unmanaged systemd unit at ${file}`);
130
+ }
131
+ }
132
+ catch (error) {
133
+ if (error instanceof Error &&
134
+ error.message.includes("Refusing to overwrite")) {
135
+ throw error;
136
+ }
137
+ // ENOENT ok
138
+ }
139
+ }
140
+ const serviceBody = renderSystemdService({
141
+ unitName: input.receipt.unitName,
142
+ nodeBin: input.entrypoints.nodeBin,
143
+ agentWorkerEntry: input.entrypoints.agentWorkerEntry,
144
+ controlRepoRoot: input.entrypoints.controlRepoRoot,
145
+ loopAgentBin: input.entrypoints.loopAgentBin,
146
+ managedConfigHash: input.receipt.managedConfigHash,
147
+ });
148
+ const timerBody = renderSystemdTimer({
149
+ unitName: input.receipt.unitName,
150
+ intervalSec: input.receipt.intervalSec,
151
+ managedConfigHash: input.receipt.managedConfigHash,
152
+ });
153
+ await writeFile(svc, serviceBody, "utf-8");
154
+ await writeFile(tmr, timerBody, "utf-8");
155
+ const reload = await runSystemctl(["daemon-reload"]);
156
+ if (reload.code !== 0) {
157
+ throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr || reload.stdout}`);
158
+ }
159
+ const enable = await runSystemctl([
160
+ "enable",
161
+ "--now",
162
+ `${input.receipt.unitName}.timer`,
163
+ ]);
164
+ if (enable.code !== 0) {
165
+ throw new Error(`systemctl --user enable --now failed: ${enable.stderr || enable.stdout}`);
166
+ }
167
+ },
168
+ async uninstall(input) {
169
+ const svc = servicePath(input.receipt.unitName);
170
+ const tmr = timerPath(input.receipt.unitName);
171
+ for (const file of [svc, tmr]) {
172
+ try {
173
+ const existing = await readFile(file, "utf-8");
174
+ if (!existing.includes(MANAGED_MARKER) &&
175
+ !existing.includes(input.receipt.managedConfigHash)) {
176
+ throw new Error(`Refusing to uninstall non-managed systemd unit at ${file}`);
177
+ }
178
+ }
179
+ catch (error) {
180
+ if (error &&
181
+ typeof error === "object" &&
182
+ "code" in error &&
183
+ error.code === "ENOENT") {
184
+ continue;
185
+ }
186
+ if (error instanceof Error &&
187
+ error.message.includes("Refusing to uninstall")) {
188
+ throw error;
189
+ }
190
+ }
191
+ }
192
+ await runSystemctl(["disable", "--now", `${input.receipt.unitName}.timer`]);
193
+ await unlink(svc).catch(() => { });
194
+ await unlink(tmr).catch(() => { });
195
+ await runSystemctl(["daemon-reload"]);
196
+ },
197
+ async probe(input) {
198
+ const unitName = input.receipt?.unitName ?? input.unitName;
199
+ const tmr = timerPath(unitName);
200
+ let present = false;
201
+ let body = "";
202
+ try {
203
+ body = await readFile(tmr, "utf-8");
204
+ present = true;
205
+ }
206
+ catch {
207
+ present = false;
208
+ }
209
+ const isActive = await runSystemctl(["is-active", `${unitName}.timer`]);
210
+ const loaded = isActive.code === 0 && isActive.stdout.trim() === "active";
211
+ let configMatches = null;
212
+ if (present && input.receipt) {
213
+ configMatches =
214
+ body.includes(MANAGED_MARKER) &&
215
+ body.includes(input.receipt.managedConfigHash);
216
+ }
217
+ return {
218
+ present,
219
+ loaded,
220
+ configMatches,
221
+ detail: isActive.stdout.trim() || isActive.stderr.trim(),
222
+ };
223
+ },
224
+ };
@@ -0,0 +1 @@
1
+ export const MANAGED_MARKER = "tea-agent-loop-agent-night-clock";
@@ -0,0 +1,172 @@
1
+ import { spawn } from "node:child_process";
2
+ import { deriveClockUnitName } from "../clock.js";
3
+ import { MANAGED_MARKER } from "./types.js";
4
+ function taskName(unitName) {
5
+ // schtasks task path; keep under user-visible folder.
6
+ return `\\${unitName}`;
7
+ }
8
+ export function renderSchtasksCreateArgs(input) {
9
+ // Build a single command line with quoted paths. No PowerShell wrapper.
10
+ const command = [
11
+ quoteWin(input.nodeBin),
12
+ quoteWin(input.agentWorkerEntry),
13
+ "scheduler",
14
+ "tick",
15
+ "--repo",
16
+ quoteWin(input.controlRepoRoot),
17
+ "--loop-agent-bin",
18
+ quoteWin(input.loopAgentBin),
19
+ "--clock-source",
20
+ "schtasks",
21
+ "--json",
22
+ ].join(" ");
23
+ // /SC MINUTE /MO N → every N minutes (minimum 1). Sub-minute not supported by schtasks.
24
+ const minutes = Math.max(1, Math.round(input.intervalSec / 60));
25
+ return [
26
+ "/Create",
27
+ "/F",
28
+ "/TN",
29
+ taskName(input.unitName),
30
+ "/TR",
31
+ command,
32
+ "/SC",
33
+ "MINUTE",
34
+ "/MO",
35
+ String(minutes),
36
+ "/RL",
37
+ "LIMITED",
38
+ ];
39
+ }
40
+ function quoteWin(value) {
41
+ if (!/[ \t"]/.test(value))
42
+ return value;
43
+ return `"${value.replace(/"/g, '\\"')}"`;
44
+ }
45
+ async function runSchtasks(args) {
46
+ return new Promise((resolve) => {
47
+ const child = spawn("schtasks", args, {
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ windowsHide: true,
50
+ });
51
+ let stdout = "";
52
+ let stderr = "";
53
+ child.stdout.on("data", (chunk) => {
54
+ stdout += String(chunk);
55
+ });
56
+ child.stderr.on("data", (chunk) => {
57
+ stderr += String(chunk);
58
+ });
59
+ child.on("close", (code) => {
60
+ resolve({ code: code ?? 1, stdout, stderr });
61
+ });
62
+ child.on("error", (error) => {
63
+ resolve({
64
+ code: 1,
65
+ stdout,
66
+ stderr: error instanceof Error ? error.message : String(error),
67
+ });
68
+ });
69
+ });
70
+ }
71
+ export const win32SchtasksAdapter = {
72
+ provider: "schtasks",
73
+ async isAvailable() {
74
+ if (process.platform !== "win32")
75
+ return false;
76
+ const probe = await runSchtasks(["/Query"]);
77
+ // /Query without /TN lists tasks; permission errors still mean binary exists.
78
+ return (probe.code === 0 ||
79
+ probe.stderr.length > 0 ||
80
+ probe.stdout.length > 0 ||
81
+ !probe.stderr.includes("not recognized"));
82
+ },
83
+ unitName(controlRepoRoot) {
84
+ return deriveClockUnitName(controlRepoRoot);
85
+ },
86
+ renderConfig(input) {
87
+ return renderSchtasksCreateArgs({
88
+ unitName: input.receipt.unitName,
89
+ intervalSec: input.receipt.intervalSec,
90
+ nodeBin: input.entrypoints.nodeBin,
91
+ agentWorkerEntry: input.entrypoints.agentWorkerEntry,
92
+ controlRepoRoot: input.entrypoints.controlRepoRoot,
93
+ loopAgentBin: input.entrypoints.loopAgentBin,
94
+ }).join(" ");
95
+ },
96
+ async install(input) {
97
+ // Delete previous managed task if present (idempotent update).
98
+ await runSchtasks([
99
+ "/Delete",
100
+ "/F",
101
+ "/TN",
102
+ taskName(input.receipt.unitName),
103
+ ]);
104
+ const args = renderSchtasksCreateArgs({
105
+ unitName: input.receipt.unitName,
106
+ intervalSec: input.receipt.intervalSec,
107
+ nodeBin: input.entrypoints.nodeBin,
108
+ agentWorkerEntry: input.entrypoints.agentWorkerEntry,
109
+ controlRepoRoot: input.entrypoints.controlRepoRoot,
110
+ loopAgentBin: input.entrypoints.loopAgentBin,
111
+ });
112
+ const created = await runSchtasks(args);
113
+ if (created.code !== 0) {
114
+ throw new Error(`schtasks create failed: ${created.stderr || created.stdout || `exit ${created.code}`}`);
115
+ }
116
+ // Marker is stored only in install receipt; schtasks has limited annotation.
117
+ // Ownership is enforced via receipt unitName hash + TR path match on probe.
118
+ void MANAGED_MARKER;
119
+ },
120
+ async uninstall(input) {
121
+ const query = await runSchtasks([
122
+ "/Query",
123
+ "/TN",
124
+ taskName(input.receipt.unitName),
125
+ "/V",
126
+ "/FO",
127
+ "LIST",
128
+ ]);
129
+ if (query.code !== 0) {
130
+ return; // already gone
131
+ }
132
+ const body = `${query.stdout}\n${query.stderr}`;
133
+ if (!body.includes(input.receipt.controlRepoRoot) &&
134
+ !body.includes(input.receipt.agentWorkerEntry)) {
135
+ throw new Error(`Refusing to uninstall schtasks unit that does not match install receipt: ${input.receipt.unitName}`);
136
+ }
137
+ const del = await runSchtasks([
138
+ "/Delete",
139
+ "/F",
140
+ "/TN",
141
+ taskName(input.receipt.unitName),
142
+ ]);
143
+ if (del.code !== 0) {
144
+ throw new Error(`schtasks delete failed: ${del.stderr || del.stdout || `exit ${del.code}`}`);
145
+ }
146
+ },
147
+ async probe(input) {
148
+ const unitName = input.receipt?.unitName ?? input.unitName;
149
+ const query = await runSchtasks([
150
+ "/Query",
151
+ "/TN",
152
+ taskName(unitName),
153
+ "/V",
154
+ "/FO",
155
+ "LIST",
156
+ ]);
157
+ const present = query.code === 0;
158
+ const body = `${query.stdout}\n${query.stderr}`;
159
+ let configMatches = null;
160
+ if (present && input.receipt) {
161
+ configMatches =
162
+ body.includes(input.receipt.controlRepoRoot) &&
163
+ body.includes(input.receipt.agentWorkerEntry);
164
+ }
165
+ return {
166
+ present,
167
+ loaded: present,
168
+ configMatches,
169
+ detail: present ? "schtasks query ok" : query.stderr.trim() || "missing",
170
+ };
171
+ },
172
+ };
@@ -0,0 +1,284 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { CLOCK_INSTALL_SCHEMA_VERSION, DEFAULT_CLOCK_INTERVAL_SEC, classifyClockHealth, countActiveExecutionLeases, deriveClockUnitName, hashManagedConfig, readClockInstallReceipt, readClockReceipt, writeClockInstallReceipt, } from "./clock.js";
6
+ import { darwinLaunchdAdapter } from "./clock-install/darwin-launchd.js";
7
+ import { linuxSystemdUserAdapter } from "./clock-install/linux-systemd-user.js";
8
+ import { win32SchtasksAdapter } from "./clock-install/win32-schtasks.js";
9
+ import { SchedulerError, SCHEDULER_ERROR_CODES } from "./types.js";
10
+ const ADAPTERS = [
11
+ darwinLaunchdAdapter,
12
+ linuxSystemdUserAdapter,
13
+ win32SchtasksAdapter,
14
+ ];
15
+ export function selectClockAdapter(platform = process.platform) {
16
+ if (platform === "darwin")
17
+ return darwinLaunchdAdapter;
18
+ if (platform === "linux")
19
+ return linuxSystemdUserAdapter;
20
+ if (platform === "win32")
21
+ return win32SchtasksAdapter;
22
+ return null;
23
+ }
24
+ export async function resolveClockEntrypoints(input) {
25
+ const controlRepoRoot = realpathSafe(path.resolve(input.controlRepoRoot));
26
+ const nodeBin = realpathSafe(input.nodeBin ?? process.execPath);
27
+ const agentWorkerEntry = realpathSafe(input.agentWorkerEntry ?? resolveDefaultAgentWorkerEntry());
28
+ const loopAgentBin = realpathSafe(input.loopAgentBin ?? resolveDefaultLoopAgentBin());
29
+ return {
30
+ nodeBin,
31
+ agentWorkerEntry,
32
+ loopAgentBin,
33
+ controlRepoRoot,
34
+ };
35
+ }
36
+ function resolveDefaultAgentWorkerEntry() {
37
+ // Prefer package bin when running from published install; fall back to
38
+ // dist entry relative to this module when developing from source.
39
+ const here = path.dirname(fileURLToPath(import.meta.url));
40
+ // src/worker/scheduler → package root is ../../..
41
+ // dist/worker/scheduler → package root is ../../..
42
+ const packageRoot = path.resolve(here, "../../..");
43
+ const binEntry = path.join(packageRoot, "bin", "agent-worker.js");
44
+ const distEntry = path.join(packageRoot, "dist", "worker", "cli.js");
45
+ // Prefer bin wrapper (stable public entry).
46
+ try {
47
+ realpathSync(binEntry);
48
+ return binEntry;
49
+ }
50
+ catch {
51
+ return distEntry;
52
+ }
53
+ }
54
+ function resolveDefaultLoopAgentBin() {
55
+ const here = path.dirname(fileURLToPath(import.meta.url));
56
+ const packageRoot = path.resolve(here, "../../..");
57
+ const binEntry = path.join(packageRoot, "bin", "loop-agent.js");
58
+ try {
59
+ realpathSync(binEntry);
60
+ return binEntry;
61
+ }
62
+ catch {
63
+ return "loop-agent";
64
+ }
65
+ }
66
+ function realpathSafe(candidate) {
67
+ try {
68
+ return realpathSync(path.resolve(candidate));
69
+ }
70
+ catch {
71
+ return path.resolve(candidate);
72
+ }
73
+ }
74
+ export async function installClockTimer(input) {
75
+ const now = input.now ?? new Date();
76
+ const adapter = input.adapter ?? selectClockAdapter();
77
+ if (!adapter) {
78
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `No supported clock provider on platform ${process.platform}`);
79
+ }
80
+ const available = await adapter.isAvailable();
81
+ if (!available) {
82
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `Clock provider ${adapter.provider} is not available in this environment (unsupported or user manager missing)`);
83
+ }
84
+ const intervalSec = normalizeInterval(input.intervalSec);
85
+ const entrypoints = await resolveClockEntrypoints({
86
+ controlRepoRoot: input.controlRepoRoot,
87
+ loopAgentBin: input.loopAgentBin,
88
+ agentWorkerEntry: input.agentWorkerEntry,
89
+ nodeBin: input.nodeBin,
90
+ });
91
+ // Verify entrypoints exist.
92
+ for (const [label, p] of [
93
+ ["nodeBin", entrypoints.nodeBin],
94
+ ["agentWorkerEntry", entrypoints.agentWorkerEntry],
95
+ ]) {
96
+ try {
97
+ await access(p);
98
+ }
99
+ catch {
100
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `Cannot resolve ${label} for clock install: ${p}`);
101
+ }
102
+ }
103
+ const unitName = adapter.unitName(entrypoints.controlRepoRoot);
104
+ const managedConfigHash = hashManagedConfig({
105
+ provider: adapter.provider,
106
+ unitName,
107
+ controlRepoRoot: entrypoints.controlRepoRoot,
108
+ intervalSec,
109
+ nodeBin: entrypoints.nodeBin,
110
+ agentWorkerEntry: entrypoints.agentWorkerEntry,
111
+ loopAgentBin: entrypoints.loopAgentBin,
112
+ });
113
+ const existing = await readClockInstallReceipt(entrypoints.controlRepoRoot);
114
+ if (existing &&
115
+ existing.managedConfigHash === managedConfigHash &&
116
+ existing.provider === adapter.provider &&
117
+ existing.unitName === unitName) {
118
+ // Still ensure OS unit is present (idempotent re-apply).
119
+ await adapter.install({
120
+ receipt: existing,
121
+ entrypoints,
122
+ });
123
+ return {
124
+ schemaVersion: 1,
125
+ action: "unchanged",
126
+ provider: adapter.provider,
127
+ receipt: existing,
128
+ message: "Clock timer already installed with matching config.",
129
+ };
130
+ }
131
+ const receipt = {
132
+ schemaVersion: CLOCK_INSTALL_SCHEMA_VERSION,
133
+ provider: adapter.provider,
134
+ unitName,
135
+ controlRepoRoot: entrypoints.controlRepoRoot,
136
+ intervalSec,
137
+ nodeBin: entrypoints.nodeBin,
138
+ agentWorkerEntry: entrypoints.agentWorkerEntry,
139
+ loopAgentBin: entrypoints.loopAgentBin,
140
+ managedConfigHash,
141
+ installedAt: existing?.installedAt ?? now.toISOString(),
142
+ updatedAt: now.toISOString(),
143
+ };
144
+ await adapter.install({ receipt, entrypoints });
145
+ await writeClockInstallReceipt(entrypoints.controlRepoRoot, receipt);
146
+ return {
147
+ schemaVersion: 1,
148
+ action: existing ? "updated" : "installed",
149
+ provider: adapter.provider,
150
+ receipt,
151
+ message: existing ? "Clock timer updated." : "Clock timer installed.",
152
+ };
153
+ }
154
+ export async function uninstallClockTimer(input) {
155
+ const controlRepoRoot = realpathSafe(path.resolve(input.controlRepoRoot));
156
+ const receipt = await readClockInstallReceipt(controlRepoRoot);
157
+ if (!receipt) {
158
+ return {
159
+ schemaVersion: 1,
160
+ action: "already-absent",
161
+ provider: null,
162
+ unitName: null,
163
+ message: "No managed clock install receipt found.",
164
+ };
165
+ }
166
+ const adapter = input.adapter ??
167
+ ADAPTERS.find((item) => item.provider === receipt.provider) ??
168
+ selectClockAdapter();
169
+ if (!adapter || adapter.provider !== receipt.provider) {
170
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `Cannot uninstall clock: provider ${receipt.provider} adapter unavailable on this host`);
171
+ }
172
+ await adapter.uninstall({ receipt });
173
+ // Keep install receipt for audit? Plan: uninstall removes managed unit but
174
+ // "保留 install receipt 历史" — we rewrite a tombstone by deleting active
175
+ // receipt content is ambiguous. Keep file but mark by rewriting unit gone:
176
+ // Plan says "uninstall 保留 clock.json、install receipt 历史". So do NOT delete.
177
+ // Status will see unit missing → drifted/missing via probe.
178
+ // Better: leave receipt; status uses probe.present=false → drifted with reinstall hint.
179
+ // For cleaner UX after intentional uninstall, write a soft marker by clearing
180
+ // is not specified. Follow plan: keep receipt file as audit.
181
+ return {
182
+ schemaVersion: 1,
183
+ action: "uninstalled",
184
+ provider: receipt.provider,
185
+ unitName: receipt.unitName,
186
+ message: "Managed clock timer removed. Install receipt and tick history retained.",
187
+ };
188
+ }
189
+ export async function statusClockTimer(input) {
190
+ const now = input.now ?? new Date();
191
+ const controlRepoRoot = realpathSafe(path.resolve(input.controlRepoRoot));
192
+ const install = await readClockInstallReceipt(controlRepoRoot);
193
+ const receipt = await readClockReceipt(controlRepoRoot);
194
+ const adapter = input.adapter ??
195
+ (install
196
+ ? (ADAPTERS.find((item) => item.provider === install.provider) ?? null)
197
+ : selectClockAdapter());
198
+ let providerSupported = true;
199
+ if (adapter) {
200
+ providerSupported = await adapter.isAvailable();
201
+ }
202
+ else {
203
+ providerSupported = false;
204
+ }
205
+ let probe = null;
206
+ if (adapter && install) {
207
+ const unitProbe = await adapter.probe({
208
+ receipt: install,
209
+ unitName: install.unitName,
210
+ });
211
+ probe = unitProbe;
212
+ }
213
+ else if (adapter) {
214
+ const unitName = deriveClockUnitName(controlRepoRoot);
215
+ const unitProbe = await adapter.probe({ receipt: null, unitName });
216
+ probe = unitProbe;
217
+ }
218
+ // Path drift: compare current entrypoints to install receipt.
219
+ let pathsMatch = null;
220
+ if (install) {
221
+ try {
222
+ const current = await resolveClockEntrypoints({
223
+ controlRepoRoot,
224
+ loopAgentBin: install.loopAgentBin,
225
+ agentWorkerEntry: install.agentWorkerEntry,
226
+ nodeBin: install.nodeBin,
227
+ });
228
+ // If operator upgraded package, default entry may differ from frozen receipt.
229
+ const liveDefault = await resolveClockEntrypoints({ controlRepoRoot });
230
+ pathsMatch =
231
+ install.nodeBin === current.nodeBin &&
232
+ install.agentWorkerEntry === current.agentWorkerEntry &&
233
+ // soft: live package entry still equals frozen entry
234
+ install.agentWorkerEntry === liveDefault.agentWorkerEntry;
235
+ }
236
+ catch {
237
+ pathsMatch = false;
238
+ }
239
+ }
240
+ const activeLeaseCount = await countActiveExecutionLeases(controlRepoRoot, now);
241
+ const health = classifyClockHealth({
242
+ now,
243
+ receipt,
244
+ install,
245
+ unitPresent: probe?.present ?? null,
246
+ unitLoaded: probe?.loaded ?? null,
247
+ configMatches: probe?.configMatches ?? null,
248
+ pathsMatch,
249
+ providerSupported,
250
+ activeLeaseCount,
251
+ defaultMaxConcurrency: 1,
252
+ });
253
+ return {
254
+ schemaVersion: 1,
255
+ health,
256
+ install,
257
+ probe,
258
+ };
259
+ }
260
+ function normalizeInterval(raw) {
261
+ const value = raw ?? DEFAULT_CLOCK_INTERVAL_SEC;
262
+ if (!Number.isFinite(value) || value < 15 || value > 3600) {
263
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `intervalSec must be between 15 and 3600, got ${raw}`);
264
+ }
265
+ return Math.floor(value);
266
+ }
267
+ export function detectClockSourceFromEnv(explicit) {
268
+ if (explicit === "manual" ||
269
+ explicit === "launchd" ||
270
+ explicit === "systemd-user" ||
271
+ explicit === "schtasks" ||
272
+ explicit === "clock-run") {
273
+ return explicit;
274
+ }
275
+ // Heuristics for OS-invoked processes (best-effort).
276
+ if (process.env.LAUNCH_JOB_LABEL || process.env.XPC_SERVICE_NAME) {
277
+ return "launchd";
278
+ }
279
+ if (process.env.INVOCATION_ID || process.env.JOURNAL_STREAM) {
280
+ return "systemd-user";
281
+ }
282
+ return "manual";
283
+ }
284
+ export { darwinLaunchdAdapter, linuxSystemdUserAdapter, win32SchtasksAdapter };