viewable-one 0.0.0-stage → 0.12.1
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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// ONE DAEMON, EVERY REGISTERED FOLDER.
|
|
2
|
+
//
|
|
3
|
+
// The runner was written to watch the folder it was started in, because it
|
|
4
|
+
// was started by a person standing in that folder. A LaunchAgent is started
|
|
5
|
+
// by launchd at login, standing nowhere, so something has to say which
|
|
6
|
+
// folders it is for - the registry (lib/registry.mjs) - and the watcher has
|
|
7
|
+
// to hold all of them at once.
|
|
8
|
+
//
|
|
9
|
+
// WHAT DOES NOT CHANGE, and this is the part that matters: each folder gets
|
|
10
|
+
// the SAME `watch()` it always got, writing its own mark file in its own
|
|
11
|
+
// `.glytch/inbox/spawn/.runner.json`. The app's contract is per folder and
|
|
12
|
+
// it is untouched - an app that has never heard of this module reads exactly
|
|
13
|
+
// the file it read before. Nothing about a session, a claim, a receipt or a
|
|
14
|
+
// budget becomes machine-wide.
|
|
15
|
+
//
|
|
16
|
+
// ONE RUNNER ID FOR THE PROCESS (#813). Every folder used to get its own, but
|
|
17
|
+
// one `register` names every folder with the id of whichever folder sent it,
|
|
18
|
+
// so the rows' runner id flipped between them. The app now matches the
|
|
19
|
+
// runner's presence on its push topic against that id, so it is one id.
|
|
20
|
+
//
|
|
21
|
+
// A FOLDER THAT HAS DISAPPEARED IS ONE LOG LINE, NEVER AN EXIT. A machine-
|
|
22
|
+
// wide daemon meets deleted and moved project folders as an ordinary event,
|
|
23
|
+
// and quitting over one would stop the other five.
|
|
24
|
+
//
|
|
25
|
+
// PM_BOARD_ROOT is NOT handled here: with it set, `glytch runner watch` hands
|
|
26
|
+
// straight over to spawn-runner.mjs, so that path is exactly today's, down to
|
|
27
|
+
// the same process.
|
|
28
|
+
//
|
|
29
|
+
// THE REGISTRY IS READ AGAIN WHILE THE DAEMON RUNS (2026-09-06, ruling
|
|
30
|
+
// B). The first test pass of brief 19 found the install-once promise broken
|
|
31
|
+
// for the SECOND project on a machine: `glytch init` there added the folder
|
|
32
|
+
// to folders.json and said "folder registered", `glytch doctor` said "the
|
|
33
|
+
// daemon watches this folder", and nothing did - this module read the
|
|
34
|
+
// registry once at start, so the new folder needed `launchctl kickstart -k`
|
|
35
|
+
// by hand. Option A, having `init` restart the daemon, was rejected: a
|
|
36
|
+
// restart kills whatever session is running under it in another project. So
|
|
37
|
+
// the daemon watches the registry FILE, and a folder registered while it runs
|
|
38
|
+
// gets its sync and its watcher without anything being restarted.
|
|
39
|
+
|
|
40
|
+
import { makeRunnerHealth } from "./runner-health.mjs";
|
|
41
|
+
import { watch as fsWatch } from "node:fs";
|
|
42
|
+
import { stat } from "node:fs/promises";
|
|
43
|
+
import { basename, dirname } from "node:path";
|
|
44
|
+
import { makeAgentProbe } from "./detect.mjs";
|
|
45
|
+
import { platformMark } from "./platform.mjs";
|
|
46
|
+
import { ensureGlytchFolder } from "./glytchFolder.mjs";
|
|
47
|
+
import { readRegistryChecked, registryPath } from "./registry.mjs";
|
|
48
|
+
import { watch, newRunnerId } from "./runner.mjs";
|
|
49
|
+
import { RUNNER_BEAT_MS } from "./spawn.mjs";
|
|
50
|
+
import { makeHeartbeatHub, makeRegistrar, REGISTER_REFRESH_MS } from "./runnerPace.mjs";
|
|
51
|
+
import { beatBaseMs, makeRunnerRealtime } from "./runnerRealtime.mjs";
|
|
52
|
+
import { sweepAll } from "./sweep.mjs";
|
|
53
|
+
import { syncFolder } from "./sync.mjs";
|
|
54
|
+
import { makeUpdateChecker, packageVersion } from "./update.mjs";
|
|
55
|
+
|
|
56
|
+
export const SWEEP_INTERVAL_MS = 86_400_000;
|
|
57
|
+
/** How often the registry is re-read when fs.watch tells us nothing. The
|
|
58
|
+
* floor, not the mechanism: fs.watch answers in milliseconds when it fires at
|
|
59
|
+
* all, and this is what catches the cases where it does not - a network home
|
|
60
|
+
* directory, an editor that swaps the file in a way the watch misses, a
|
|
61
|
+
* platform that drops the event. A minute is invisible to a person typing
|
|
62
|
+
* `viewable init` and then switching to the app. */
|
|
63
|
+
export const REGISTRY_POLL_MS = 60_000;
|
|
64
|
+
|
|
65
|
+
const isDir = async (path) => {
|
|
66
|
+
try {
|
|
67
|
+
return (await stat(path)).isDirectory();
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Watch every registered folder until `signal` aborts.
|
|
74
|
+
*
|
|
75
|
+
* Returns {watched, missing} after signal-driven shutdown and after every
|
|
76
|
+
* folder's watch has ended. An empty registry still waits for new folders. */
|
|
77
|
+
export async function watchRegistered({
|
|
78
|
+
env = process.env,
|
|
79
|
+
settings,
|
|
80
|
+
signal,
|
|
81
|
+
log = () => {},
|
|
82
|
+
folders,
|
|
83
|
+
sweep = sweepAll,
|
|
84
|
+
sync = syncFolder,
|
|
85
|
+
updater = makeUpdateChecker({ log }),
|
|
86
|
+
// WHAT CODING AGENTS THIS MACHINE HAS (brief 19 stage 2). Same shape as
|
|
87
|
+
// `updater`: a cache with its own clock, so the liveness beat never waits
|
|
88
|
+
// on `claude --version`. Injected for the tests, which must not spawn three
|
|
89
|
+
// child processes to find out which folders got watched.
|
|
90
|
+
probe = makeAgentProbe({ log }),
|
|
91
|
+
// WHAT MACHINE THIS IS (brief 21 session 5, ruling 3A). Injected for
|
|
92
|
+
// the same reason `probe` is, and for one more: the mark's contents are
|
|
93
|
+
// asserted verbatim in the tests, and a key whose value is whichever
|
|
94
|
+
// machine the suite happens to be running on would make those assertions
|
|
95
|
+
// say something different on the windows-latest runner.
|
|
96
|
+
platform = platformMark(),
|
|
97
|
+
sweepIntervalMs = SWEEP_INTERVAL_MS,
|
|
98
|
+
registryPollMs = REGISTRY_POLL_MS,
|
|
99
|
+
// The one seam the tests use. `watch()` polls until it is aborted and does
|
|
100
|
+
// real filesystem and process work per pass; what this module has to be
|
|
101
|
+
// held to is WHICH folders it starts one for, with what settings - so the
|
|
102
|
+
// watcher itself is injectable and the default is the real one.
|
|
103
|
+
watchOne = watch,
|
|
104
|
+
// THE PUSH CHANNEL (#813), one per process. Injected for the tests, which
|
|
105
|
+
// pass a fake, or `() => null` for none (every 10 s, as before).
|
|
106
|
+
makeRealtime = makeRunnerRealtime,
|
|
107
|
+
} = {}) {
|
|
108
|
+
// An injected list is the whole list: a caller that says which folders to
|
|
109
|
+
// watch is not asking the registry anything, so the live re-read below
|
|
110
|
+
// stands down for it. Nothing in the package passes this; the tests do.
|
|
111
|
+
const pinned = folders !== undefined;
|
|
112
|
+
// The Glytch folder (glytchFolder.mjs) is made and registered first, so a
|
|
113
|
+
// computer with no folder linked yet still has one to work in.
|
|
114
|
+
if (!pinned) await ensureGlytchFolder(env, log);
|
|
115
|
+
// When the registry file was last written, read before the list is: a
|
|
116
|
+
// rewrite that keeps the same folders is `glytch init` asking for a beat.
|
|
117
|
+
const registryStamp = async () => (await stat(registryPath(env)).catch(() => null))?.mtimeMs ?? null;
|
|
118
|
+
let stampSeen = pinned ? null : await registryStamp();
|
|
119
|
+
const registered = folders ?? (await readRegistryChecked(env)).folders;
|
|
120
|
+
const watched = [];
|
|
121
|
+
const missing = [];
|
|
122
|
+
const first = [];
|
|
123
|
+
for (const folder of registered) {
|
|
124
|
+
if (await isDir(folder)) first.push(folder);
|
|
125
|
+
else missing.push(folder);
|
|
126
|
+
}
|
|
127
|
+
for (const gone of missing) log(`skipping ${gone}: the folder no longer exists`);
|
|
128
|
+
|
|
129
|
+
if (first.length === 0) {
|
|
130
|
+
log("no folders registered - run `viewable init` in a project folder first.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// SYNC ON EVERY START (ruling 5), and on every folder that joins later.
|
|
134
|
+
// Put back .mcp.json, the opencode registration, the registry entry and the
|
|
135
|
+
// login daemon's captured PATH before the first session of the day rather
|
|
136
|
+
// than after it fails.
|
|
137
|
+
//
|
|
138
|
+
// daemon:true is load-bearing (2026-09-06 finding): THIS process is the
|
|
139
|
+
// login daemon the OS is holding, so if the sync's fourth row ever
|
|
140
|
+
// restarted it from in here, it would stop the very process running this
|
|
141
|
+
// loop mid-sync. `daemon:true` keeps that row read-only for this one
|
|
142
|
+
// caller; `glytch init` and `glytch doctor`, which are never the daemon,
|
|
143
|
+
// still restart a stopped one.
|
|
144
|
+
//
|
|
145
|
+
// 2026-09-07, brief 21 session 4: the row now goes through
|
|
146
|
+
// lib/logindaemon.mjs, and the guard covers all three implementations
|
|
147
|
+
// rather than launchd alone - `launchctl load`, `schtasks /run` and
|
|
148
|
+
// `systemctl --user enable --now` would each restart this process.
|
|
149
|
+
const syncOne = async (folder) => {
|
|
150
|
+
try {
|
|
151
|
+
for (const r of await sync(folder, { env, daemon: true })) {
|
|
152
|
+
if (!r.ok) log(`[${folder}] ${r.label}: ${r.detail}`);
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
log(`[${folder}] sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
for (const folder of first) await syncOne(folder);
|
|
159
|
+
|
|
160
|
+
// THE SWEEP, on start and once a day. Both are the same call; nothing about
|
|
161
|
+
// it is a schedule the app can see.
|
|
162
|
+
const runSweep = async () => {
|
|
163
|
+
try {
|
|
164
|
+
await sweep({ env, log });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
log(`sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
await runSweep();
|
|
170
|
+
const sweeping = setInterval(() => void runSweep(), sweepIntervalMs);
|
|
171
|
+
sweeping.unref?.();
|
|
172
|
+
|
|
173
|
+
// AT START, per the brief, and every five minutes after: the probe's own
|
|
174
|
+
// timer does the second half, and this await does the first, so the very
|
|
175
|
+
// first mark a person sees already carries the `agents` block rather than
|
|
176
|
+
// arriving bare and filling in a beat later.
|
|
177
|
+
await probe.once();
|
|
178
|
+
|
|
179
|
+
// The mark file gains `version`, `updateAvailable`, `agents` and (brief 21
|
|
180
|
+
// session 5, ruling 3A) `platform` here, and only here: `watch()`
|
|
181
|
+
// writes whatever `markExtra()` returns beside `at`, and both caches answer
|
|
182
|
+
// from memory so the liveness beat never waits on npm or on a child
|
|
183
|
+
// process. `platform` is the machine the SESSION will run on, which is the
|
|
184
|
+
// only thing the app can use to pick the words a spawn prompt carries for
|
|
185
|
+
// starting a detached dev server - the browser may be on another box
|
|
186
|
+
// entirely (lib/platform.mjs says why it is not `navigator`).
|
|
187
|
+
const health = makeRunnerHealth({ env, version: updater.state?.().version ?? packageVersion() });
|
|
188
|
+
const markExtra = () => ({ ...platform, ...updater.mark(), ...probe.mark(), ...health.mark() });
|
|
189
|
+
|
|
190
|
+
// ONE REGISTER AND ONE HEARTBEAT FOR THE WHOLE PROCESS (ticket e8,
|
|
191
|
+
// 2026-10-03; #616, 2026-10-16). Every folder's beat registers the whole
|
|
192
|
+
// folder list and heartbeats its own folder, so five folders sent the same
|
|
193
|
+
// register five times and five heartbeats every ten seconds, and tripped
|
|
194
|
+
// Cloudflare's per-IP rate limit. The folders share one registrar, which
|
|
195
|
+
// repeats an unchanged list every 5 minutes, and one heartbeat hub, which
|
|
196
|
+
// sends one heartbeat for all of them per beat (lib/runnerPace.mjs).
|
|
197
|
+
const beatMs = settings?.beatMs ?? RUNNER_BEAT_MS;
|
|
198
|
+
const registrar = makeRegistrar({ ttlMs: REGISTER_REFRESH_MS, failureTtlMs: 0.9 * beatMs });
|
|
199
|
+
const runnerId = settings?.runnerId ?? newRunnerId();
|
|
200
|
+
// PUSH (#813): joined, every folder beats every 5 minutes and the hub's
|
|
201
|
+
// shared answer lives as long, so the folders still share one heartbeat.
|
|
202
|
+
const realtime = makeRealtime({
|
|
203
|
+
runnerId,
|
|
204
|
+
version: updater.state?.().version ?? packageVersion(),
|
|
205
|
+
runnerApi: settings?.runnerApi,
|
|
206
|
+
env,
|
|
207
|
+
fetchImpl: settings?.fetchImpl,
|
|
208
|
+
log,
|
|
209
|
+
});
|
|
210
|
+
const heartbeats = makeHeartbeatHub({
|
|
211
|
+
ttlMs: () => 0.9 * beatBaseMs(realtime, beatMs, settings?.pushBeatMs),
|
|
212
|
+
failureTtlMs: 0.9 * beatMs,
|
|
213
|
+
});
|
|
214
|
+
realtime?.start();
|
|
215
|
+
// A BEAT ON REQUEST (#813 review). `glytch init` in a folder this daemon
|
|
216
|
+
// already watches leaves it running and waits for a beat that came after
|
|
217
|
+
// it started (runner-health.mjs); joined, the next one could be 5 minutes
|
|
218
|
+
// away. init rewrites the registry instead, and every watcher beats once,
|
|
219
|
+
// sharing one heartbeat (one `at` for all of them).
|
|
220
|
+
const beatAsks = new Set();
|
|
221
|
+
const beatRequests = { subscribe: (fn) => { beatAsks.add(fn); return () => beatAsks.delete(fn); } };
|
|
222
|
+
const askBeat = () => {
|
|
223
|
+
const at = Date.now();
|
|
224
|
+
for (const fn of [...beatAsks]) fn(at);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ONE ENTRY PER FOLDER, AND NEVER TWO. `running` is what makes "never watch
|
|
228
|
+
// a folder twice" a fact rather than an intention: every start goes through
|
|
229
|
+
// startFolder, and startFolder returns at once if the folder is already in
|
|
230
|
+
// here. A dead watcher retries after one ordinary heartbeat, independently
|
|
231
|
+
// of registry polling or push availability. The delay prevents a broken
|
|
232
|
+
// folder from spinning; removal and shutdown cancel it.
|
|
233
|
+
const running = new Map();
|
|
234
|
+
// Woken whenever the set of watchers changes, so the wait at the bottom
|
|
235
|
+
// notices a folder that joined after it started waiting.
|
|
236
|
+
let wake;
|
|
237
|
+
let fireWake = () => {};
|
|
238
|
+
const armWake = () => {
|
|
239
|
+
wake = new Promise((resolve) => {
|
|
240
|
+
fireWake = resolve;
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
armWake();
|
|
244
|
+
const bump = () => {
|
|
245
|
+
const fire = fireWake;
|
|
246
|
+
armWake();
|
|
247
|
+
fire();
|
|
248
|
+
};
|
|
249
|
+
signal?.addEventListener("abort", bump, { once: true });
|
|
250
|
+
|
|
251
|
+
const startFolder = (folder) => {
|
|
252
|
+
if (running.has(folder)) return;
|
|
253
|
+
if (signal?.aborted) return;
|
|
254
|
+
// A STOP THAT IS A REAL STOP. `watch()`'s only way out is its signal (see
|
|
255
|
+
// its doc comment: "there is no other way out, because a runner that
|
|
256
|
+
// stops itself on a condition is a runner that starts itself on one"), so
|
|
257
|
+
// each folder gets its own AbortController chained to the daemon's. That
|
|
258
|
+
// is a clean stop and not a leak: the watcher drains the sessions it
|
|
259
|
+
// already started, writes their receipts, and clears the folder's mark
|
|
260
|
+
// file on the way out, exactly as Ctrl-C does today.
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const onParentAbort = () => controller.abort();
|
|
263
|
+
signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
264
|
+
const entry = { controller, done: false, leaving: false };
|
|
265
|
+
let started;
|
|
266
|
+
try {
|
|
267
|
+
started = watchOne(folder, { ...settings, runnerId, realtime, onHealthy: (path) => health.beat(path), markExtra, registrar, heartbeats, beatRequests, refreshAgents: () => probe.once() }, {
|
|
268
|
+
signal: controller.signal,
|
|
269
|
+
log: (line) => log(`[${folder}] ${line}`),
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
started = Promise.reject(error);
|
|
273
|
+
}
|
|
274
|
+
entry.promise = Promise.resolve(started)
|
|
275
|
+
.catch((error) => {
|
|
276
|
+
// One folder's watcher dying must not take the others down: a board
|
|
277
|
+
// root that becomes unreadable is exactly the case this daemon
|
|
278
|
+
// exists to survive.
|
|
279
|
+
log(`[${folder}] watcher stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
280
|
+
})
|
|
281
|
+
.finally(() => {
|
|
282
|
+
entry.done = true;
|
|
283
|
+
signal?.removeEventListener("abort", onParentAbort);
|
|
284
|
+
if (entry.leaving && running.get(folder) === entry) running.delete(folder);
|
|
285
|
+
else if (!signal?.aborted) {
|
|
286
|
+
entry.retry = setTimeout(() => {
|
|
287
|
+
if (entry.leaving || signal?.aborted || running.get(folder) !== entry) return;
|
|
288
|
+
running.delete(folder);
|
|
289
|
+
startFolder(folder);
|
|
290
|
+
}, beatMs);
|
|
291
|
+
entry.retry.unref?.();
|
|
292
|
+
}
|
|
293
|
+
bump();
|
|
294
|
+
});
|
|
295
|
+
running.set(folder, entry);
|
|
296
|
+
if (!watched.includes(folder)) watched.push(folder);
|
|
297
|
+
bump();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
for (const folder of first) startFolder(folder);
|
|
301
|
+
|
|
302
|
+
// THE REGISTRY, RE-READ. Everything below exists so that `glytch init` in a
|
|
303
|
+
// second project is watched without a restart (2026-09-06, ruling B).
|
|
304
|
+
const announcedMissing = new Set(missing); // logged once each, not once a minute
|
|
305
|
+
let complained = false;
|
|
306
|
+
let refreshing = false;
|
|
307
|
+
let refreshAgain = false;
|
|
308
|
+
const refresh = async () => {
|
|
309
|
+
if (pinned) return;
|
|
310
|
+
if (refreshing) {
|
|
311
|
+
refreshAgain = true;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
refreshing = true;
|
|
315
|
+
try {
|
|
316
|
+
do {
|
|
317
|
+
refreshAgain = false;
|
|
318
|
+
const stamp = await registryStamp();
|
|
319
|
+
const rewritten = stamp !== null && stamp !== stampSeen;
|
|
320
|
+
stampSeen = stamp;
|
|
321
|
+
const { folders: now, ok } = await readRegistryChecked(env);
|
|
322
|
+
if (!ok) {
|
|
323
|
+
// A REGISTRY WE CANNOT READ IS NOT NEWS. Half-written, hand-edited
|
|
324
|
+
// into invalid JSON, briefly absent: none of those mean the folders
|
|
325
|
+
// were unregistered, and acting on the empty list they parse to
|
|
326
|
+
// would stop every watcher on the machine. Keep the last good list,
|
|
327
|
+
// say so once, and let the next good read speak.
|
|
328
|
+
if (!complained) {
|
|
329
|
+
complained = true;
|
|
330
|
+
log(`${registryPath(env)} could not be read as a folder list - keeping the folders already watched`);
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
complained = false;
|
|
335
|
+
const wanted = new Set(now);
|
|
336
|
+
for (const [folder, entry] of running) {
|
|
337
|
+
if (wanted.has(folder) || entry.leaving) continue;
|
|
338
|
+
entry.leaving = true;
|
|
339
|
+
clearTimeout(entry.retry);
|
|
340
|
+
if (entry.done) running.delete(folder);
|
|
341
|
+
log(`${folder} left the registry - stopping its watcher`);
|
|
342
|
+
entry.controller.abort();
|
|
343
|
+
}
|
|
344
|
+
for (const folder of now) {
|
|
345
|
+
if (running.has(folder)) continue;
|
|
346
|
+
if (!(await isDir(folder))) {
|
|
347
|
+
if (!announcedMissing.has(folder)) {
|
|
348
|
+
announcedMissing.add(folder);
|
|
349
|
+
missing.push(folder);
|
|
350
|
+
log(`skipping ${folder}: the folder no longer exists`);
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
announcedMissing.delete(folder);
|
|
355
|
+
log(`${folder} joined the registry - syncing and watching it`);
|
|
356
|
+
await syncOne(folder);
|
|
357
|
+
startFolder(folder);
|
|
358
|
+
}
|
|
359
|
+
if (rewritten) askBeat();
|
|
360
|
+
} while (refreshAgain);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Never an exit. The poll comes round again in a minute.
|
|
363
|
+
log(`re-reading the registry failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
364
|
+
} finally {
|
|
365
|
+
refreshing = false;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// BOTH THE FILE AND ITS DIRECTORY. `folders.json` is written by
|
|
370
|
+
// writeFileAtomic, which renames a temp file over it, so the inode a watch
|
|
371
|
+
// on the file is holding is not the one the next read opens - the file
|
|
372
|
+
// watch sees the first write and then nothing. The directory watch sees the
|
|
373
|
+
// rename. Neither is trusted: setInterval below is the floor, and a
|
|
374
|
+
// platform where fs.watch does nothing at all still picks a new folder up
|
|
375
|
+
// within registryPollMs.
|
|
376
|
+
const regPath = registryPath(env);
|
|
377
|
+
const regName = basename(regPath);
|
|
378
|
+
const fsWatchers = [];
|
|
379
|
+
const attach = (target, onlyRegistry) => {
|
|
380
|
+
try {
|
|
381
|
+
const w = fsWatch(target, (_event, filename) => {
|
|
382
|
+
if (onlyRegistry && filename && basename(String(filename)) !== regName) return;
|
|
383
|
+
void refresh();
|
|
384
|
+
});
|
|
385
|
+
w.on("error", () => {}); // a dead watch is not an exit; the poll covers it
|
|
386
|
+
w.unref?.();
|
|
387
|
+
fsWatchers.push(w);
|
|
388
|
+
} catch {
|
|
389
|
+
// The file may not exist yet, the directory may not be watchable: the
|
|
390
|
+
// poll is the answer to both.
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
if (!pinned) {
|
|
394
|
+
attach(regPath, false);
|
|
395
|
+
attach(dirname(regPath), true);
|
|
396
|
+
}
|
|
397
|
+
const polling = setInterval(() => void refresh(), registryPollMs);
|
|
398
|
+
polling.unref?.();
|
|
399
|
+
|
|
400
|
+
// Registry watches, polling and wake promises are unref'd or bare. Keep
|
|
401
|
+
// the daemon alive even before its first folder or after its last watcher.
|
|
402
|
+
const keepAlive = setInterval(() => {}, 2 ** 30);
|
|
403
|
+
try {
|
|
404
|
+
// Wait for abort, then drain every watcher. Until then an empty set must
|
|
405
|
+
// still wait for registry changes rather than ending the daemon.
|
|
406
|
+
for (;;) {
|
|
407
|
+
const pending = [];
|
|
408
|
+
for (const entry of running.values()) if (!entry.done) pending.push(entry.promise);
|
|
409
|
+
if (signal?.aborted && pending.length === 0) break;
|
|
410
|
+
await Promise.race([...pending, wake]);
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
clearInterval(keepAlive);
|
|
414
|
+
for (const entry of running.values()) clearTimeout(entry.retry);
|
|
415
|
+
await realtime?.close();
|
|
416
|
+
signal?.removeEventListener("abort", bump);
|
|
417
|
+
clearInterval(sweeping);
|
|
418
|
+
clearInterval(polling);
|
|
419
|
+
for (const w of fsWatchers) {
|
|
420
|
+
try {
|
|
421
|
+
w.close();
|
|
422
|
+
} catch {
|
|
423
|
+
// closing twice, or after an error, is not a failure worth a line
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { watched, missing };
|
|
428
|
+
}
|