resumecontext 0.1.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 +27 -0
- package/dist/agentConfig.js +202 -0
- package/dist/agentConfigWithDaemon.js +42 -0
- package/dist/apiClient.js +54 -0
- package/dist/browser.js +20 -0
- package/dist/cloudApi.js +15 -0
- package/dist/commands/accept.js +20 -0
- package/dist/commands/agents.js +35 -0
- package/dist/commands/auth.js +55 -0
- package/dist/commands/daemon.js +99 -0
- package/dist/commands/init.js +59 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/mcp.js +54 -0
- package/dist/commands/members.js +20 -0
- package/dist/commands/projects.js +55 -0
- package/dist/commands/revoke.js +15 -0
- package/dist/commands/share.js +18 -0
- package/dist/commands/sync.js +59 -0
- package/dist/commands/uninstall.js +61 -0
- package/dist/constants.js +61 -0
- package/dist/daemon.js +409 -0
- package/dist/daemonService.js +326 -0
- package/dist/deps.js +1 -0
- package/dist/dev.js +32 -0
- package/dist/device.js +40 -0
- package/dist/httpCloudApi.js +61 -0
- package/dist/index.js +160 -0
- package/dist/localCapture.js +18 -0
- package/dist/localHistory/claudeCode.js +82 -0
- package/dist/localHistory/codex.js +106 -0
- package/dist/localHistory/cursor.js +492 -0
- package/dist/localHistory/index.js +96 -0
- package/dist/localHistory/opencode.js +148 -0
- package/dist/localHistory/registry.js +66 -0
- package/dist/localHistory/shared.js +174 -0
- package/dist/paths.js +85 -0
- package/dist/projectRoot.js +77 -0
- package/dist/session.js +36 -0
- package/dist/syncCore.js +108 -0
- package/dist/syncState.js +51 -0
- package/dist/ui.js +289 -0
- package/dist/utils.js +41 -0
- package/dist/version.js +43 -0
- package/package.json +64 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auto-sync background work, and the machinery that keeps it scheduled.
|
|
3
|
+
* Once a project finishes agent configuration, the CLI registers it here
|
|
4
|
+
* and makes sure the OS is scheduled to run it; from then on, syncing that
|
|
5
|
+
* project happens on its own every DAEMON_INTERVAL_MS without the user ever
|
|
6
|
+
* running `resumecontext sync` again.
|
|
7
|
+
*
|
|
8
|
+
* "Making sure it's scheduled" means registering a real, OS-managed
|
|
9
|
+
* mechanism -- launchd StartInterval on macOS, a systemd --user timer on
|
|
10
|
+
* Linux, see daemonService.ts -- so this survives a reboot and keeps firing
|
|
11
|
+
* even if a given run crashes. There is deliberately no fallback to a plain
|
|
12
|
+
* detached subprocess: a background process a `kill` or a reboot silently
|
|
13
|
+
* ends for good, with nothing to notice or revive it, is not what
|
|
14
|
+
* "auto-sync" is supposed to mean. On a platform with neither mechanism,
|
|
15
|
+
* ensureDaemonRunning does nothing and auto-sync simply isn't available
|
|
16
|
+
* there -- `resumecontext sync` still works manually.
|
|
17
|
+
*
|
|
18
|
+
* Each tick is its own short-lived process, not one long-running loop.
|
|
19
|
+
* Earlier this ran as a single persistent process (launchd RunAtLoad +
|
|
20
|
+
* KeepAlive supervising an in-process `while (true)` loop) on the theory
|
|
21
|
+
* that starting a fresh Node process every 5 seconds costs more than a
|
|
22
|
+
* resident one sleeping between ticks. That's true, but it traded away
|
|
23
|
+
* something more important: in production, one tick hit an unrelated bug
|
|
24
|
+
* (a stack-overflow deep in a local-history parser) and the process was
|
|
25
|
+
* observed pinned at sustained high CPU minutes later, with nothing further
|
|
26
|
+
* ever logged -- one bad tick took down every tick after it, silently,
|
|
27
|
+
* until someone happened to notice. A supervisor restarts a process that
|
|
28
|
+
* *exits*; it does nothing for one that's merely stuck. A fresh process per
|
|
29
|
+
* tick is self-healing from exactly that failure mode: a hung or crashed
|
|
30
|
+
* tick wastes at most one interval, and the next scheduled tick runs
|
|
31
|
+
* unaffected in a clean process.
|
|
32
|
+
*
|
|
33
|
+
* Because ticks are now separate processes, TWO ticks can be scheduled
|
|
34
|
+
* before the first finishes (a slow scan, a hung tick that hasn't been
|
|
35
|
+
* cleaned up yet) -- see acquireLock below for how that's prevented.
|
|
36
|
+
*
|
|
37
|
+
* A second project finishing setup while auto-sync is already scheduled
|
|
38
|
+
* does NOT need a second registration: registerProject just adds an entry
|
|
39
|
+
* to a shared JSON file that every tick re-reads at the top, so it picks
|
|
40
|
+
* up the new project on its own within one interval -- see runDaemonTick
|
|
41
|
+
* below.
|
|
42
|
+
*/
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { agentConfigFile, daemonRegistryFile, daemonLogFile, daemonLockFile, daemonFingerprintCacheFile, syncStateFile, } from "./paths.js";
|
|
46
|
+
import { apiErrorStatus } from "./apiClient.js";
|
|
47
|
+
import { readAgentConfig } from "./agentConfig.js";
|
|
48
|
+
import { readCredentials } from "./session.js";
|
|
49
|
+
import { scanForNewTurns, pushNewTurns } from "./syncCore.js";
|
|
50
|
+
import { MAX_LOG_BYTES } from "./constants.js";
|
|
51
|
+
import { installPersistentService } from "./daemonService.js";
|
|
52
|
+
import { fingerprintDirsForConfig } from "./localHistory/registry.js";
|
|
53
|
+
import { findProjectRoot } from "./projectRoot.js";
|
|
54
|
+
export function readRegistry() {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(daemonRegistryFile(), "utf-8"));
|
|
57
|
+
if (parsed && typeof parsed.projects === "object")
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// missing or unparseable -- fall through to empty
|
|
62
|
+
}
|
|
63
|
+
return { projects: {} };
|
|
64
|
+
}
|
|
65
|
+
function writeRegistry(registry) {
|
|
66
|
+
const file = daemonRegistryFile();
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(registry, null, 2));
|
|
69
|
+
}
|
|
70
|
+
/** Adds (or updates) one project in the shared registry. Idempotent --
|
|
71
|
+
* calling this for a project that's already registered just rewrites the
|
|
72
|
+
* same entry, so every command that touches a project's agent config can
|
|
73
|
+
* call it unconditionally rather than tracking "did I already register
|
|
74
|
+
* this." */
|
|
75
|
+
export function registerProject(projectId, root) {
|
|
76
|
+
const registry = readRegistry();
|
|
77
|
+
registry.projects[projectId] = { root };
|
|
78
|
+
writeRegistry(registry);
|
|
79
|
+
}
|
|
80
|
+
/** Removes one project from the registry and deletes its per-project local
|
|
81
|
+
* files -- the counterpart to registerProject, called when a project is
|
|
82
|
+
* confirmed gone (a 404 from the cloud API, meaning the project id no longer
|
|
83
|
+
* exists there at all, whether it was deleted through this CLI or the web
|
|
84
|
+
* UI). Idempotent. A project id is a UUID that's never reused, so once
|
|
85
|
+
* confirmed gone there's no future scenario where keeping its agent config
|
|
86
|
+
* or sync-state helps; deleting them here, not just the registry entry, is
|
|
87
|
+
* what keeps ~/.resumecontext/agents and ~/.resumecontext/sync-state from
|
|
88
|
+
* silently accumulating one dead file per deleted project forever. Does not
|
|
89
|
+
* touch `.resumecontext.json` or anything else. */
|
|
90
|
+
export function deregisterProject(projectId) {
|
|
91
|
+
const registry = readRegistry();
|
|
92
|
+
if (!(projectId in registry.projects))
|
|
93
|
+
return;
|
|
94
|
+
delete registry.projects[projectId];
|
|
95
|
+
writeRegistry(registry);
|
|
96
|
+
fs.rmSync(agentConfigFile(projectId), { force: true });
|
|
97
|
+
fs.rmSync(syncStateFile(projectId), { force: true });
|
|
98
|
+
}
|
|
99
|
+
// ---- lifecycle: making sure auto-sync is scheduled with the OS --------
|
|
100
|
+
/** Registers auto-sync as a real OS-managed schedule if this platform
|
|
101
|
+
* supports one (see daemonService.ts) -- no fallback of any kind on a
|
|
102
|
+
* platform with neither launchd nor systemd, so what's registered is
|
|
103
|
+
* always something the OS itself keeps firing after a crash or a reboot,
|
|
104
|
+
* never a subprocess someone can just `kill`.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent: safe to call from every command that touches a project's
|
|
107
|
+
* agent config, not just the first time. Returns which platform mechanism
|
|
108
|
+
* applies (or "unsupported") and whether this call actually installed
|
|
109
|
+
* anything, so callers can tell the user when auto-sync isn't available
|
|
110
|
+
* here at all.
|
|
111
|
+
*
|
|
112
|
+
* `installService` is injectable so tests can verify what would be
|
|
113
|
+
* installed -- which mechanism, which plan -- without ever registering a
|
|
114
|
+
* real OS service. */
|
|
115
|
+
export function ensureDaemonRunning(installService = installPersistentService) {
|
|
116
|
+
return installService();
|
|
117
|
+
}
|
|
118
|
+
// ---- log rotation -------------------------------------------------------
|
|
119
|
+
/** Keeps daemonLogFile() bounded, retaining one previous generation.
|
|
120
|
+
*
|
|
121
|
+
* Truncates in place rather than renaming: launchd (StandardOutPath) and
|
|
122
|
+
* systemd (StandardOutput=append:) open this file themselves per-run and
|
|
123
|
+
* would recreate it anyway, but truncating keeps this correct even if a
|
|
124
|
+
* platform ever held the descriptor open across runs. Called at the start
|
|
125
|
+
* of every tick, not just some of them, since ticks no longer share a
|
|
126
|
+
* process to schedule it from.
|
|
127
|
+
*
|
|
128
|
+
* `fsImpl` is injectable so tests can drive the size logic without a real
|
|
129
|
+
* log file, matching the pattern used in daemonService.ts. */
|
|
130
|
+
export function rotateLogIfNeeded(fsImpl = fs, logPath = daemonLogFile(), maxBytes = MAX_LOG_BYTES) {
|
|
131
|
+
let size;
|
|
132
|
+
try {
|
|
133
|
+
size = fsImpl.statSync(logPath).size;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false; // no log yet -- nothing to rotate
|
|
137
|
+
}
|
|
138
|
+
if (size <= maxBytes)
|
|
139
|
+
return false;
|
|
140
|
+
fsImpl.copyFileSync(logPath, `${logPath}.1`);
|
|
141
|
+
fsImpl.truncateSync(logPath, 0);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/** True if a process with this pid exists and we have permission to signal
|
|
145
|
+
* it -- signal 0 sends nothing, it only probes. Throws ESRCH ("no such
|
|
146
|
+
* process") for a dead pid, EPERM for one that exists but isn't ours;
|
|
147
|
+
* EPERM still means "alive", just not signalable, so only ESRCH (and any
|
|
148
|
+
* other throw, conservatively) counts as dead. */
|
|
149
|
+
function isProcessAlive(pid) {
|
|
150
|
+
try {
|
|
151
|
+
process.kill(pid, 0);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return err.code === "EPERM";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Tries to take the lock for this tick. Returns false (meaning: skip this
|
|
159
|
+
* run entirely) when another tick already holds it AND that tick's process
|
|
160
|
+
* is still alive. A lock file left behind by a process that's no longer
|
|
161
|
+
* running (killed -9, machine slept mid-write, an old crash) is stale and
|
|
162
|
+
* self-heals here rather than wedging auto-sync forever -- there is no
|
|
163
|
+
* "unsupported platform" fallback for locking the way there is for
|
|
164
|
+
* scheduling, since flock-equivalent primitives (exclusive file create,
|
|
165
|
+
* process.kill(pid, 0)) are POSIX and both platforms we support have them.
|
|
166
|
+
*
|
|
167
|
+
* `fsImpl`/`pid` are injectable so tests can exercise every branch (fresh
|
|
168
|
+
* lock, live holder, stale holder) without real processes or a real lock
|
|
169
|
+
* file. */
|
|
170
|
+
export function acquireLock(fsImpl = fs, pid = process.pid, lockPath = daemonLockFile()) {
|
|
171
|
+
fsImpl.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
172
|
+
try {
|
|
173
|
+
fsImpl.writeFileSync(lockPath, String(pid), { flag: "wx" }); // atomic create-exclusive
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
if (err.code !== "EEXIST")
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
const holderPid = Number(fsImpl.readFileSync(lockPath, "utf8"));
|
|
181
|
+
if (Number.isInteger(holderPid) && isProcessAlive(holderPid))
|
|
182
|
+
return false;
|
|
183
|
+
// Stale: the holder is gone. Take over -- best-effort against another
|
|
184
|
+
// tick racing to do the same thing at the exact same moment, which is
|
|
185
|
+
// rare enough (two ticks, both finding the SAME stale lock, within the
|
|
186
|
+
// gap between the read above and this write) not to guard further; the
|
|
187
|
+
// worst case is two ticks briefly overlapping once, not corruption.
|
|
188
|
+
fsImpl.writeFileSync(lockPath, String(pid));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/** Releases the lock, but only if it's still ours -- guards against
|
|
192
|
+
* releasing a lock a different process has since taken over (the stale
|
|
193
|
+
* takeover race noted in acquireLock above). */
|
|
194
|
+
export function releaseLock(fsImpl = fs, pid = process.pid, lockPath = daemonLockFile()) {
|
|
195
|
+
try {
|
|
196
|
+
const holderPid = Number(fsImpl.readFileSync(lockPath, "utf8"));
|
|
197
|
+
if (holderPid === pid)
|
|
198
|
+
fsImpl.rmSync(lockPath, { force: true });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// already gone, or unreadable -- nothing more to do
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ---- change detection -----------------------------------------------------
|
|
205
|
+
/** SQLite's WAL-mode sidecar files -- `<db>-shm` (shared memory index) and
|
|
206
|
+
* `<db>-wal` (write-ahead log). Excluded from the fingerprint below: opening
|
|
207
|
+
* a WAL-mode database AT ALL, even with `readOnly: true`, touches -shm's
|
|
208
|
+
* mtime as an inherent part of SQLite establishing a consistent read
|
|
209
|
+
* snapshot -- verified directly against a real Cursor store.db (its -shm
|
|
210
|
+
* mtime advanced on every read-only open; -journal is rollback-mode's
|
|
211
|
+
* equivalent, same idea). Cursor's own history is exactly this shape (see
|
|
212
|
+
* cursor.ts), which without this exclusion turned the fingerprint gate into
|
|
213
|
+
* a self-defeating loop: scanning to check for real changes touched these
|
|
214
|
+
* files, which changed the next tick's fingerprint, which triggered another
|
|
215
|
+
* full scan, forever -- a ~73MB Cursor history measured at a genuinely
|
|
216
|
+
* unchanging multi-second cost on EVERY tick, not just when something
|
|
217
|
+
* actually changed. Fixing this here, not in cursor.ts's read path, keeps
|
|
218
|
+
* that path free to keep reading live/current data -- the alternative
|
|
219
|
+
* (SQLite's `immutable=1` connection mode) would avoid the mtime side
|
|
220
|
+
* effect but does so by promising SQLite the file won't change during the
|
|
221
|
+
* read, which is false while Cursor itself might be actively appending. */
|
|
222
|
+
function isFingerprintNoise(filename) {
|
|
223
|
+
return filename.endsWith("-shm") || filename.endsWith("-wal") || filename.endsWith("-journal");
|
|
224
|
+
}
|
|
225
|
+
/** A cheap fingerprint of everything under `dirs`: newest mtime, total
|
|
226
|
+
* size, and file count.
|
|
227
|
+
*
|
|
228
|
+
* This exists because a full scan is expensive and almost always finds
|
|
229
|
+
* nothing. collectLocalTurns parses every session file of every project on
|
|
230
|
+
* the machine and only then filters down to the one project being synced
|
|
231
|
+
* -- on a 73MB history that measured 442ms of CPU and a 410MB allocation
|
|
232
|
+
* spike, repeated on every tick regardless of interval, to usually
|
|
233
|
+
* discover nothing changed. Stat'ing the tree first turns the common case
|
|
234
|
+
* into a handful of syscalls.
|
|
235
|
+
*
|
|
236
|
+
* Size and count are folded in alongside mtime deliberately: mtime has
|
|
237
|
+
* millisecond granularity, so an append landing in the same millisecond as
|
|
238
|
+
* our stat would otherwise be invisible until the next unrelated write. */
|
|
239
|
+
export function fingerprintDirs(dirs) {
|
|
240
|
+
let newest = 0;
|
|
241
|
+
let totalSize = 0;
|
|
242
|
+
let fileCount = 0;
|
|
243
|
+
const walk = (dir) => {
|
|
244
|
+
let entries;
|
|
245
|
+
try {
|
|
246
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return; // unreadable or deleted -- treat as contributing nothing
|
|
250
|
+
}
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
if (isFingerprintNoise(entry.name))
|
|
253
|
+
continue;
|
|
254
|
+
const full = path.join(dir, entry.name);
|
|
255
|
+
if (entry.isDirectory()) {
|
|
256
|
+
walk(full);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const st = fs.statSync(full);
|
|
261
|
+
newest = Math.max(newest, st.mtimeMs);
|
|
262
|
+
totalSize += st.size;
|
|
263
|
+
fileCount += 1;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// vanished between readdir and stat -- ignore
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
for (const dir of dirs)
|
|
271
|
+
walk(dir);
|
|
272
|
+
return `${newest}:${totalSize}:${fileCount}`;
|
|
273
|
+
}
|
|
274
|
+
function readFingerprintCache() {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(fs.readFileSync(daemonFingerprintCacheFile(), "utf-8"));
|
|
277
|
+
if (parsed && typeof parsed === "object")
|
|
278
|
+
return parsed;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// missing or unparseable -- fall through to empty, same as readRegistry
|
|
282
|
+
}
|
|
283
|
+
return {};
|
|
284
|
+
}
|
|
285
|
+
function writeFingerprintCache(cache) {
|
|
286
|
+
const file = daemonFingerprintCacheFile();
|
|
287
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
288
|
+
fs.writeFileSync(file, JSON.stringify(cache, null, 2));
|
|
289
|
+
}
|
|
290
|
+
// ---- the tick itself: only ever run inside a scheduled daemon process ----
|
|
291
|
+
function log(message) {
|
|
292
|
+
console.log(`[${new Date().toISOString()}] ${message}`);
|
|
293
|
+
}
|
|
294
|
+
export async function tick(cloudApi, localCapture) {
|
|
295
|
+
const creds = readCredentials();
|
|
296
|
+
if (!creds)
|
|
297
|
+
return; // logged out -- nothing to sync until logged back in
|
|
298
|
+
const fingerprintCache = readFingerprintCache();
|
|
299
|
+
let fingerprintCacheChanged = false;
|
|
300
|
+
for (const [projectId, { root: registeredRoot }] of Object.entries(readRegistry().projects)) {
|
|
301
|
+
try {
|
|
302
|
+
// The registry remembers where this project was last explicitly set up,
|
|
303
|
+
// but the marker remains the source of truth. This lets a marker moved
|
|
304
|
+
// to a parent directory heal itself on the next tick, while a deleted
|
|
305
|
+
// or replaced marker affects only this one project.
|
|
306
|
+
const resolved = findProjectRoot(registeredRoot);
|
|
307
|
+
if (!resolved.projectId) {
|
|
308
|
+
log(`${projectId}: no .resumecontext.json found at ${registeredRoot} or its parents -- skipped`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (resolved.projectId !== projectId) {
|
|
312
|
+
log(`${projectId}: .resumecontext.json at ${resolved.root} belongs to ${resolved.projectId} -- skipped`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const root = resolved.root;
|
|
316
|
+
if (path.resolve(root) !== path.resolve(registeredRoot)) {
|
|
317
|
+
registerProject(projectId, root);
|
|
318
|
+
// The configured agent paths can resolve differently under the new
|
|
319
|
+
// root (notably Cursor), so force one scan instead of trusting a
|
|
320
|
+
// fingerprint produced for the old location.
|
|
321
|
+
delete fingerprintCache[projectId];
|
|
322
|
+
fingerprintCacheChanged = true;
|
|
323
|
+
log(`${projectId}: project marker moved to ${root} -- updated registration`);
|
|
324
|
+
}
|
|
325
|
+
const agentConfig = readAgentConfig(projectId);
|
|
326
|
+
if (!agentConfig)
|
|
327
|
+
continue; // agent setup never finished for this one
|
|
328
|
+
// Skip the expensive scan entirely when nothing on disk has moved.
|
|
329
|
+
// A config with no directories at all is left ungated on purpose: its
|
|
330
|
+
// fingerprint would be a constant carrying no information, and there
|
|
331
|
+
// is nothing to scan in that case anyway, so gating buys nothing and
|
|
332
|
+
// would only obscure why the scan was skipped.
|
|
333
|
+
const dirs = fingerprintDirsForConfig(root, agentConfig);
|
|
334
|
+
const fingerprint = dirs.length > 0 ? fingerprintDirs(dirs) : null;
|
|
335
|
+
if (fingerprint !== null && fingerprintCache[projectId] === fingerprint)
|
|
336
|
+
continue;
|
|
337
|
+
const { newTurns } = await scanForNewTurns(localCapture, root, agentConfig, projectId);
|
|
338
|
+
if (newTurns.length > 0) {
|
|
339
|
+
const { accepted } = await pushNewTurns(cloudApi, creds.token, projectId, newTurns);
|
|
340
|
+
log(`${projectId}: pushed ${accepted} new turn(s)`);
|
|
341
|
+
}
|
|
342
|
+
// Only recorded once the work above fully succeeded -- a push that
|
|
343
|
+
// throws must leave the cached fingerprint unchanged so the next
|
|
344
|
+
// tick retries rather than treating the failure as "handled".
|
|
345
|
+
if (fingerprint !== null) {
|
|
346
|
+
fingerprintCache[projectId] = fingerprint;
|
|
347
|
+
fingerprintCacheChanged = true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
if (apiErrorStatus(err) === 404) {
|
|
352
|
+
deregisterProject(projectId);
|
|
353
|
+
log(`${projectId}: project no longer exists (deleted) -- stopped syncing it`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
// One project's failure (network hiccup, revoked access, a deleted
|
|
357
|
+
// directory) must never stop this tick from covering the rest, or
|
|
358
|
+
// from trying this project again next tick.
|
|
359
|
+
log(`${projectId}: sync failed -- ${err instanceof Error ? err.message : String(err)}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (fingerprintCacheChanged)
|
|
364
|
+
writeFingerprintCache(fingerprintCache);
|
|
365
|
+
}
|
|
366
|
+
/** The entire body of one daemon tick (see the hidden `__daemon-run`
|
|
367
|
+
* command in index.ts) -- runs the shared per-project tick() once and
|
|
368
|
+
* returns, letting the OS scheduler (launchd StartInterval / a systemd
|
|
369
|
+
* timer, see daemonService.ts) be the thing that calls this again in
|
|
370
|
+
* DAEMON_INTERVAL_MS. See this file's module doc comment for why a fresh
|
|
371
|
+
* process per tick, rather than one persistent process looping forever,
|
|
372
|
+
* is the design here.
|
|
373
|
+
*
|
|
374
|
+
* Takes the lock first and does nothing else at all if it can't get it --
|
|
375
|
+
* see acquireLock's doc comment. That's the expected, silent outcome when
|
|
376
|
+
* the previous tick is still running (a slow scan) or hasn't been cleaned
|
|
377
|
+
* up yet, and deliberately NOT logged: at DAEMON_INTERVAL_MS this is common
|
|
378
|
+
* enough (a slightly slow scan on one tick pushes the next one into an
|
|
379
|
+
* overlap) that logging it every time would itself be the noise problem
|
|
380
|
+
* this function's start/finish lines exist to avoid.
|
|
381
|
+
*
|
|
382
|
+
* Explicit "tick started"/"tick finished" lines bracket every real run --
|
|
383
|
+
* without them, a hung tick (the exact failure mode this whole per-tick-
|
|
384
|
+
* process design exists to survive, see the module doc comment) looks
|
|
385
|
+
* IDENTICAL in the log to a healthy one that simply hasn't logged a push
|
|
386
|
+
* yet: both show nothing after the last line. A start with no matching
|
|
387
|
+
* finish is the signal that this particular tick never came back, which a
|
|
388
|
+
* bare list of push/failure lines can't tell you on its own. The elapsed
|
|
389
|
+
* time on the finish line is the same debugging value: a normal tick
|
|
390
|
+
* finishes in well under a second; a tick that took 90 seconds to report
|
|
391
|
+
* "finished" already tells you where to look before you go near `ps`. */
|
|
392
|
+
export async function runDaemonTick(cloudApi, localCapture) {
|
|
393
|
+
if (!acquireLock())
|
|
394
|
+
return;
|
|
395
|
+
rotateLogIfNeeded();
|
|
396
|
+
const startedAt = Date.now();
|
|
397
|
+
log("tick started");
|
|
398
|
+
try {
|
|
399
|
+
await tick(cloudApi, localCapture);
|
|
400
|
+
log(`tick finished (${Date.now() - startedAt}ms)`);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
log(`tick finished (${Date.now() - startedAt}ms) -- unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
releaseLock();
|
|
408
|
+
}
|
|
409
|
+
}
|