usagemax 0.3.2 → 0.3.3
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 +41 -0
- package/package.json +2 -1
- package/src/cli.js +28 -1
- package/src/resume.js +3 -1
- package/src/service.js +150 -0
package/README.md
CHANGED
|
@@ -124,6 +124,47 @@ It never automatically replays an old authoritative deletion against newer data.
|
|
|
124
124
|
Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
|
|
125
125
|
self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
|
|
126
126
|
|
|
127
|
+
## Optional automatic sync
|
|
128
|
+
|
|
129
|
+
Automatic sync is **off by default**. Install a persistent CLI first (a bunx/npx
|
|
130
|
+
cache can disappear), link the computer if needed, then opt in:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bun install -g usagemax
|
|
134
|
+
usagemax service install # approximately every 15 minutes
|
|
135
|
+
usagemax service install --every 30 # change interval; 5–1440 minutes
|
|
136
|
+
usagemax service status # scheduler reachability + last result
|
|
137
|
+
usagemax service run # run now, respecting failure backoff
|
|
138
|
+
usagemax service uninstall # stop future jobs; retain account/data
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Uses a user LaunchAgent on macOS, a user systemd timer on Linux/WSL, and Task
|
|
142
|
+
Scheduler on Windows. No admin/root access, resident daemon, file watcher,
|
|
143
|
+
automatic package updates, or package downloads per run. Each job runs the normal
|
|
144
|
+
incremental sync and exits. No-change runs skip parsing/uploading when the source
|
|
145
|
+
inventory is complete and unchanged. The metadata inventory still costs disk I/O;
|
|
146
|
+
an active or first/full-history scan costs more. This is periodic, not live telemetry.
|
|
147
|
+
|
|
148
|
+
Schedules are spread by up to a minute. macOS/Linux jobs have reduced CPU/I/O
|
|
149
|
+
priority. Windows jobs require a signed-in user and defer starting on battery.
|
|
150
|
+
Jobs do not wake a sleeping computer. Linux needs a running systemd user manager;
|
|
151
|
+
WSL must already be running with systemd enabled. UsageMax does not enable linger
|
|
152
|
+
or keep a WSL distro alive. Missed intervals are not replayed as a backlog.
|
|
153
|
+
|
|
154
|
+
The existing collector lock prevents overlapping uploads. Failures back off
|
|
155
|
+
exponentially (up to six hours); manually running `usagemax sync` is available for
|
|
156
|
+
diagnosis without waiting. `service-state.json` retains only the latest bounded
|
|
157
|
+
status, timestamps, duration, and failure count, not raw logs or credentials.
|
|
158
|
+
If a process was forcibly killed, inspect the PID reported by `sync` before
|
|
159
|
+
removing its stale `collector.lock`; never delete `config.json` to retry.
|
|
160
|
+
|
|
161
|
+
Re-run `service install` after upgrading/moving the CLI or changing source-path
|
|
162
|
+
environment variables. Only an explicit allowlist of discovery settings is saved,
|
|
163
|
+
not your shell's secrets. Jobs run from your home directory; repository-local
|
|
164
|
+
`.ccusage/ccusage.json` configuration is not automatically used. Keep the runtime
|
|
165
|
+
and global CLI installed. `service uninstall` leaves existing usage, link credentials,
|
|
166
|
+
checkpoints, and last-run status intact; an in-flight sync may finish.
|
|
167
|
+
|
|
127
168
|
## Interrupted uploads and protocol 0.3.1
|
|
128
169
|
|
|
129
170
|
Before uploading, the CLI saves the exact run, ordered request payloads and next
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "UsageMax",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"src/sources.js",
|
|
26
26
|
"src/transport.js",
|
|
27
27
|
"src/resume.js",
|
|
28
|
+
"src/service.js",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE"
|
|
30
31
|
],
|
package/src/cli.js
CHANGED
|
@@ -8,17 +8,19 @@ import { homedir, platform } from "node:os";
|
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
11
12
|
|
|
12
13
|
import { prepareArchiveRecovery } from "./archives.js";
|
|
13
14
|
import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
|
|
14
15
|
import { stableInstallationId } from "./installation.js";
|
|
16
|
+
import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
|
|
15
17
|
import { requestSnapshot } from "./transport.js";
|
|
16
18
|
import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
|
|
17
19
|
import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
18
20
|
|
|
19
21
|
const require = createRequire(import.meta.url);
|
|
20
22
|
const executeFile = promisify(execFile);
|
|
21
|
-
const VERSION = "0.3.
|
|
23
|
+
const VERSION = "0.3.3";
|
|
22
24
|
const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
|
|
23
25
|
const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
|
|
24
26
|
const CONFIG_FILE = "config.json";
|
|
@@ -107,6 +109,9 @@ function help() {
|
|
|
107
109
|
process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
|
|
108
110
|
process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
|
|
109
111
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
112
|
+
process.stdout.write(" usagemax service install [--every 15]\n");
|
|
113
|
+
process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
|
|
114
|
+
process.stdout.write(" usagemax service status|run|uninstall\n");
|
|
110
115
|
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
111
116
|
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
112
117
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
@@ -127,6 +132,8 @@ async function ccusageJson(config, { full = false, env } = {}) {
|
|
|
127
132
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
128
133
|
encoding: "utf8",
|
|
129
134
|
maxBuffer: MAX_REPORT_BYTES,
|
|
135
|
+
timeout: 10 * 60 * 1000,
|
|
136
|
+
killSignal: "SIGKILL",
|
|
130
137
|
env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
|
|
131
138
|
});
|
|
132
139
|
return JSON.parse(stdout);
|
|
@@ -446,6 +453,26 @@ async function main() {
|
|
|
446
453
|
const command = args[0] || "sync";
|
|
447
454
|
if (["--help", "-h", "help"].includes(command)) return help();
|
|
448
455
|
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
456
|
+
if (command === "service") {
|
|
457
|
+
const action = args[1] || "status";
|
|
458
|
+
const directory = option(args, "--config-dir") || configDirectory();
|
|
459
|
+
process.env.USAGEMAX_CONFIG_DIR = directory;
|
|
460
|
+
let result;
|
|
461
|
+
if (action === "run") {
|
|
462
|
+
result = await runScheduledSync(directory, () => withConfigLock(directory, () => sync(["--json"])));
|
|
463
|
+
if (result.status === "error") process.exitCode = 1;
|
|
464
|
+
} else if (action === "status") {
|
|
465
|
+
result = await manageService(action, { directory, cli: fileURLToPath(import.meta.url) });
|
|
466
|
+
} else {
|
|
467
|
+
result = await withConfigLock(directory, async () => {
|
|
468
|
+
if (action === "install" && !await readConfig()) throw new Error("Link this computer before enabling automatic sync.");
|
|
469
|
+
if (args.includes("--every") && option(args, "--every") === undefined) throw new Error("--every requires a number of minutes.");
|
|
470
|
+
return manageService(action, { directory, cli: fileURLToPath(import.meta.url), minutes: args.includes("--every") ? intervalMinutes(option(args, "--every")) : undefined });
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
449
476
|
if (command === "report") return report(args.slice(1));
|
|
450
477
|
if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
|
|
451
478
|
return withConfigLock(configDirectory(), async () => {
|
package/src/resume.js
CHANGED
|
@@ -48,7 +48,9 @@ export async function withConfigLock(directory, action) {
|
|
|
48
48
|
} catch (error) {
|
|
49
49
|
if (error.code !== "EEXIST") throw error;
|
|
50
50
|
const owner = (await readFile(path, "utf8").catch(() => "unknown")).trim();
|
|
51
|
-
|
|
51
|
+
const busy = new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
|
|
52
|
+
busy.code = "USAGEMAX_BUSY";
|
|
53
|
+
throw busy;
|
|
52
54
|
}
|
|
53
55
|
try {
|
|
54
56
|
return await action();
|
package/src/service.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { access, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const ENV_KEYS = ["PATH", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA", "WSL_DISTRO_NAME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENCODE_DATA_DIR", "AMP_DATA_DIR", "DROID_SESSIONS_DIR", "CODEBUFF_DATA_DIR", "HERMES_HOME", "PI_AGENT_DIR", "GOOSE_PATH_ROOT", "OPENCLAW_DIR", "KILO_DATA_DIR", "KIMI_DATA_DIR", "QWEN_DATA_DIR", "GEMINI_DATA_DIR", "GROK_HOME", "COPILOT_OTEL_FILE_EXPORTER_PATH", "USAGEMAX_ADDITIONAL_HOME", "USAGEMAX_WSL_USERS_DIR", "CCUSAGE_MODEL_ALIASES"];
|
|
10
|
+
const xml = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
11
|
+
const unitQuote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", () => "$$")}"`;
|
|
12
|
+
const windowsQuote = (value) => `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
|
|
13
|
+
|
|
14
|
+
export function intervalMinutes(value = 15) {
|
|
15
|
+
if (!/^\d+$/.test(String(value)) || Number(value) < 5 || Number(value) > 1440) throw new Error("--every must be an integer from 5 to 1440 minutes.");
|
|
16
|
+
return Number(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function capturedEnvironment(env) {
|
|
20
|
+
// Never persist the whole shell environment: it can contain provider secrets.
|
|
21
|
+
return Object.fromEntries(ENV_KEYS.filter((key) => typeof env[key] === "string").map((key) => [key, env[key]]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertDurablePath(path) {
|
|
25
|
+
if (!isAbsolute(path) || /[\x00-\x1f]/.test(path) || /\/(?:_npx|cache|fnm_multishells)\//i.test(path.replaceAll("\\", "/"))) {
|
|
26
|
+
throw new Error("Automatic sync needs a persistent installation. Run `bun install -g usagemax`, then `usagemax service install`; do not install a scheduler from bunx/npx caches.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
|
|
31
|
+
minutes = intervalMinutes(minutes);
|
|
32
|
+
for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
|
|
33
|
+
const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
|
|
34
|
+
const name = `com.usagemax.sync.${id}`;
|
|
35
|
+
const args = [cli, "service", "run", "--config-dir", directory];
|
|
36
|
+
const command = [executable, ...args];
|
|
37
|
+
if (platform === "darwin") {
|
|
38
|
+
const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
|
|
39
|
+
const domain = `gui/${uid}`;
|
|
40
|
+
return { backend: "launchd", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
42
|
+
<plist version="1.0"><dict><key>Label</key><string>${name}</string>
|
|
43
|
+
<key>ProgramArguments</key><array>${command.map((s) => `<string>${xml(s)}</string>`).join("")}</array>
|
|
44
|
+
<key>WorkingDirectory</key><string>${xml(home)}</string>
|
|
45
|
+
<key>StartInterval</key><integer>${minutes * 60 + parseInt(id.slice(0, 4), 16) % 60}</integer>
|
|
46
|
+
<key>ProcessType</key><string>Background</string><key>Nice</key><integer>10</integer>
|
|
47
|
+
<key>LowPriorityIO</key><true/><key>StandardOutPath</key><string>/dev/null</string>
|
|
48
|
+
<key>StandardErrorPath</key><string>/dev/null</string></dict></plist>
|
|
49
|
+
`]], probe: ["launchctl", ["print", `${domain}/${name}`]], install: [["launchctl", ["bootstrap", domain, file]], ["launchctl", ["enable", `${domain}/${name}`]]], uninstall: [["launchctl", ["bootout", `${domain}/${name}`]]] };
|
|
50
|
+
}
|
|
51
|
+
if (platform === "linux") {
|
|
52
|
+
const root = join(configHome, "systemd", "user");
|
|
53
|
+
return { backend: "systemd", name, files: [
|
|
54
|
+
[join(root, `${name}.service`), `[Unit]\nDescription=UsageMax incremental usage sync\n[Service]\nType=oneshot\nExecStart=${command.map(unitQuote).join(" ")}\nWorkingDirectory=${unitQuote(home)}\nNice=10\nIOSchedulingClass=idle\nTimeoutStartSec=infinity\nStandardOutput=null\nStandardError=null\n`],
|
|
55
|
+
[join(root, `${name}.timer`), `[Unit]\nDescription=UsageMax automatic sync\n[Timer]\nOnActiveSec=1m\nOnUnitInactiveSec=${minutes}m\nRandomizedDelaySec=60\nAccuracySec=30s\n[Install]\nWantedBy=timers.target\n`],
|
|
56
|
+
], probe: ["systemctl", ["--user", "is-active", `${name}.timer`]], install: [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", `${name}.timer`]]], uninstall: [["systemctl", ["--user", "disable", "--now", `${name}.timer`]]] };
|
|
57
|
+
}
|
|
58
|
+
if (platform === "win32") {
|
|
59
|
+
const file = join(directory, "service-task.xml");
|
|
60
|
+
return { backend: "task-scheduler", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
|
|
61
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
62
|
+
<Triggers><TimeTrigger><Repetition><Interval>PT${minutes}M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${new Date(now + 60000).toISOString()}</StartBoundary><Enabled>true</Enabled><RandomDelay>PT1M</RandomDelay></TimeTrigger></Triggers>
|
|
63
|
+
<Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
64
|
+
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><StartWhenAvailable>true</StartWhenAvailable><Enabled>true</Enabled><WakeToRun>false</WakeToRun><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority></Settings>
|
|
65
|
+
<Actions Context="Author"><Exec><Command>${xml(executable)}</Command><Arguments>${xml(args.map(windowsQuote).join(" "))}</Arguments><WorkingDirectory>${xml(home)}</WorkingDirectory></Exec></Actions></Task>
|
|
66
|
+
`]], probe: ["schtasks.exe", ["/Query", "/TN", name, "/XML"]], install: [["schtasks.exe", ["/Create", "/TN", name, "/XML", file, "/F"]]], uninstall: [["schtasks.exe", ["/Delete", "/TN", name, "/F"]]] };
|
|
67
|
+
}
|
|
68
|
+
throw new Error("Automatic sync supports macOS, Windows, and Linux with a user systemd manager (including running WSL distributions).");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readJson(path) {
|
|
72
|
+
try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function atomicWrite(path, content) {
|
|
76
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
77
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(temp, content, { mode: 0o600, flag: "wx" });
|
|
80
|
+
await rename(temp, path);
|
|
81
|
+
} finally { await unlink(temp).catch(() => {}); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const runCommand = ([command, args]) => execute(command, args, { timeout: 15000, maxBuffer: 256 * 1024, windowsHide: true });
|
|
85
|
+
|
|
86
|
+
export async function manageService(action, { directory, cli, minutes, env = process.env, executeCommand = runCommand, home = homedir(), platform = process.platform }) {
|
|
87
|
+
directory = resolve(directory);
|
|
88
|
+
const settingsPath = join(directory, "service.json");
|
|
89
|
+
const settings = await readJson(settingsPath);
|
|
90
|
+
const configHome = settings?.configHome || env.XDG_CONFIG_HOME || join(home, ".config");
|
|
91
|
+
const plan = servicePlan({ directory, cli, executable: process.execPath, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
|
|
92
|
+
if (action === "status") {
|
|
93
|
+
let registered = false;
|
|
94
|
+
try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
|
|
95
|
+
return { installed: Boolean(settings), schedulerReachable: registered, backend: plan.backend, minutes: settings?.minutes ?? null, lastRun: await readJson(join(directory, "service-state.json")), hint: registered ? "Check lastRun for sync health. Windows task registration does not prove it is enabled." : "Scheduler is not active/reachable. On WSL, start the distro and enable its systemd user manager." };
|
|
96
|
+
}
|
|
97
|
+
if (action === "uninstall") {
|
|
98
|
+
if (!settings) return { installed: false };
|
|
99
|
+
// Failure is surfaced: do not claim removal while a scheduler might still run.
|
|
100
|
+
for (const command of plan.uninstall) await executeCommand(command);
|
|
101
|
+
for (const [file] of plan.files) await unlink(file).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
102
|
+
await unlink(settingsPath);
|
|
103
|
+
if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "daemon-reload"]]);
|
|
104
|
+
return { installed: false, retained: "Account link, usage checkpoints, and last-run status retained. An in-flight sync may finish." };
|
|
105
|
+
}
|
|
106
|
+
if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
|
|
107
|
+
assertDurablePath(await realpath(cli));
|
|
108
|
+
assertDurablePath(await realpath(process.execPath));
|
|
109
|
+
await access(join(directory, "config.json"));
|
|
110
|
+
// Check the user manager before writing anything. WSL without systemd fails clearly.
|
|
111
|
+
if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
|
|
112
|
+
if (settings) {
|
|
113
|
+
let registered = false;
|
|
114
|
+
try { await executeCommand(plan.probe); registered = true; } catch { /* Missing registration can be repaired. */ }
|
|
115
|
+
if (registered) for (const command of plan.uninstall) await executeCommand(command);
|
|
116
|
+
}
|
|
117
|
+
const next = { version: 1, minutes: intervalMinutes(minutes ?? settings?.minutes ?? 15), configHome, env: capturedEnvironment(env), installedAt: new Date().toISOString() };
|
|
118
|
+
await atomicWrite(settingsPath, JSON.stringify(next));
|
|
119
|
+
for (const [file, content] of plan.files) await atomicWrite(file, content);
|
|
120
|
+
try {
|
|
121
|
+
for (const command of plan.install) await executeCommand(command);
|
|
122
|
+
await executeCommand(plan.probe);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error("Scheduler registration failed. Settings were retained for repair; rerun service install or check service status.", { cause: error });
|
|
125
|
+
}
|
|
126
|
+
return { installed: true, backend: plan.backend, minutes: next.minutes, note: "Opt-in one-shot jobs; no daemon, auto-updater, or wake-from-sleep. Reinstall after moving/upgrading the CLI or changing source paths." };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function runScheduledSync(directory, action, { now = () => Date.now(), env = process.env } = {}) {
|
|
130
|
+
const settings = await readJson(join(directory, "service.json"));
|
|
131
|
+
if (!settings) return { status: "disabled" };
|
|
132
|
+
const path = join(directory, "service-state.json");
|
|
133
|
+
const previous = await readJson(path) || {};
|
|
134
|
+
if (previous.nextAttemptAt > now()) return { status: "backoff", nextAttemptAt: previous.nextAttemptAt };
|
|
135
|
+
Object.assign(env, capturedEnvironment(settings.env || {}));
|
|
136
|
+
const start = now();
|
|
137
|
+
let state;
|
|
138
|
+
try {
|
|
139
|
+
await action();
|
|
140
|
+
state = { status: "ok", failures: 0, lastSuccessAt: new Date(now()).toISOString() };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code === "USAGEMAX_BUSY") return { status: "busy" };
|
|
143
|
+
const failures = Math.min((previous.failures || 0) + 1, 10);
|
|
144
|
+
// Save no command output, tokens, paths, or raw server error bodies.
|
|
145
|
+
state = { status: "error", failures, lastSuccessAt: previous.lastSuccessAt ?? null, nextAttemptAt: now() + Math.min(6 * 60, settings.minutes * 2 ** failures) * 60000, hint: "Run usagemax sync manually to diagnose; checkpoints are preserved." };
|
|
146
|
+
}
|
|
147
|
+
state = { ...state, attemptedAt: new Date(start).toISOString(), durationMs: now() - start };
|
|
148
|
+
await atomicWrite(path, JSON.stringify(state));
|
|
149
|
+
return state;
|
|
150
|
+
}
|