@cortexkit/aft-bridge 0.58.0 → 0.58.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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Live reload of AFT config file edits in a host plugin.
3
+ *
4
+ * The plugin keeps its own copy of the resolved config (`ctx.config`) for the
5
+ * keys it enforces or uses itself, such as the path restriction pre-check and
6
+ * the bash wait limits. The engine applies its own keys from the same files
7
+ * separately, so this module only swaps the plugin's copy.
8
+ *
9
+ * Only keys that are safe to change under a running session are applied: the
10
+ * ones the plugin reads from `ctx.config` on every tool call. Keys that change
11
+ * the registered tools, their descriptions or the system text stay as loaded
12
+ * and apply at the next host restart; they are reported as deferred.
13
+ */
14
+ import { watch } from "node:fs";
15
+ /** How long a burst of file events must be quiet before the files are read. */
16
+ export declare const CONFIG_WATCH_DEBOUNCE_MS = 150;
17
+ /**
18
+ * Appended to a config error found by a live reload, in place of the
19
+ * "restart the host" note a startup config error carries: the plugin keeps
20
+ * running on the last valid configuration.
21
+ */
22
+ export declare const CONFIG_LIVE_KEEP_NOTE = "AFT keeps using the last valid configuration until the file is fixed.";
23
+ /** One config key the plugin applies live: how to read it and write it. */
24
+ export interface LiveConfigKey<C> {
25
+ /** Dotted config key, as the user writes it in aft.jsonc. */
26
+ name: string;
27
+ read(config: C): unknown;
28
+ /** Return a copy of `config` with this key set to `value`. */
29
+ write(config: C, value: unknown): C;
30
+ }
31
+ /** The bash settings this module needs from a host's `resolveBashConfig`. */
32
+ export interface ResolvedBashForLiveReload {
33
+ foreground_wait_window_ms: number;
34
+ host_fallback: boolean;
35
+ subagent_background: boolean;
36
+ watch_sync_max_ms: number;
37
+ }
38
+ /**
39
+ * Every key the plugins read that a live reload applies. The engine-side
40
+ * list is `apply_live_config` in `crates/aft/src/config_live.rs`.
41
+ */
42
+ export declare function aftLiveConfigKeys<C>(resolveBash: (config: C) => ResolvedBashForLiveReload & Record<string, unknown>): LiveConfigKey<C>[];
43
+ /** What {@link applyLiveConfigKeys} changed and what it left for a restart. */
44
+ export interface LiveConfigApply<C> {
45
+ /** `current` with only the changed live keys taken from `next`. */
46
+ config: C;
47
+ applied: string[];
48
+ deferred: string[];
49
+ /** Security keys a project edit would have loosened; they stay as they were. */
50
+ held?: string[];
51
+ }
52
+ /**
53
+ * Copy the live keys that differ from `current` out of `next`. `config` is a
54
+ * new object whenever something was applied, so a caller that captured the
55
+ * old one keeps a consistent snapshot. Other settings that differ from
56
+ * `baseline` (the config the plugin loaded at startup) are listed in
57
+ * `deferred` and not applied.
58
+ */
59
+ export declare function applyLiveConfigKeys<C>(current: C, next: C, keys: readonly LiveConfigKey<C>[], baseline?: C): LiveConfigApply<C>;
60
+ /** Options for {@link watchAftConfigFiles}. */
61
+ export interface WatchAftConfigFilesOptions {
62
+ /** The config files to watch (user and project `aft.jsonc`). */
63
+ paths: readonly string[];
64
+ /**
65
+ * Called after a quiet debounce window when any file's text changed.
66
+ * Returning `false` means the new text was not accepted (for example a
67
+ * half-written save): it is not recorded as seen, and the files are checked
68
+ * again shortly even if no further event arrives. Returning a map records
69
+ * the texts the callback actually accepted (path to text, `null` for an
70
+ * absent file) instead of the texts this check read, which may differ if a
71
+ * file changed in between.
72
+ */
73
+ onChange: () => unknown;
74
+ debounceMs?: number;
75
+ /**
76
+ * The texts the caller already applied, keyed by path; a path that is not
77
+ * listed was absent. When omitted, the texts are unknown, so the first
78
+ * check calls `onChange`.
79
+ */
80
+ initialTexts?: Readonly<Record<string, string>>;
81
+ /** Check the files once as soon as the watch is attached. */
82
+ checkAtStart?: boolean;
83
+ /** Test seam: replaces `fs.watch`. */
84
+ watchImpl?: typeof watch;
85
+ /** Test seam: runs between reading a directory's identity and watching it. */
86
+ beforeWatchForTest?: (dir: string) => void;
87
+ }
88
+ /**
89
+ * Watch config files the way editors save them: the parent directory is
90
+ * watched, because an editor replaces the file by renaming a temporary
91
+ * sibling over it. A directory that does not exist yet is watched through its
92
+ * nearest existing ancestor until it appears, and a watch whose directory was
93
+ * replaced or failed is re-armed. Every event in a watched directory wakes
94
+ * the check (platforms may coalesce several changes into one event naming a
95
+ * different entry), and a steady stream of events delays it at most
96
+ * {@link CONFIG_WATCH_MAX_DELAY_MS}. `onChange` runs only when a file's text
97
+ * actually differs from what was last seen. Returns a function that stops
98
+ * every watch.
99
+ */
100
+ export declare function watchAftConfigFiles(options: WatchAftConfigFilesOptions): () => void;
101
+ /**
102
+ * Result of loading the config files for a live reload. `sources` lists the
103
+ * config files the load actually read; a file it relied on last time that is
104
+ * missing from it counts as deleted.
105
+ */
106
+ export type LiveConfigLoad<C> = {
107
+ ok: true;
108
+ config: C;
109
+ sources: readonly string[];
110
+ /** The project config file's text as this load read it, or null when absent. */
111
+ projectText?: string | null;
112
+ /** The text of each file this load read, keyed by path. */
113
+ texts?: Readonly<Record<string, string>>;
114
+ } | {
115
+ ok: false;
116
+ message: string;
117
+ };
118
+ /**
119
+ * A security key and which of two values is the stricter one. A project-file
120
+ * edit may move these only towards strict while the host runs.
121
+ */
122
+ export interface LiveSecurityKey<C> {
123
+ key: LiveConfigKey<C>;
124
+ /** Whether changing the value from `current` to `next` loosens it. */
125
+ loosens(current: unknown, next: unknown): boolean;
126
+ }
127
+ /** The plugin-read security keys: the path restriction and host fallback. */
128
+ export declare function aftLiveSecurityKeys<C>(keys: readonly LiveConfigKey<C>[]): LiveSecurityKey<C>[];
129
+ /** Options for {@link startLiveConfigReload}. */
130
+ export interface LiveConfigReloadOptions<C> {
131
+ /** The user and project `aft.jsonc` files. */
132
+ paths: readonly string[];
133
+ /** Load and resolve both files. Anything but a clean load is `ok: false`. */
134
+ load(): LiveConfigLoad<C>;
135
+ /** The config files the startup load that produced `getConfig()` read. */
136
+ initialSources: readonly string[];
137
+ /** The project config text the startup load read, or null when absent. */
138
+ initialProjectText?: string | null;
139
+ /** The text of each file the startup load read, keyed by path. */
140
+ initialTexts?: Readonly<Record<string, string>>;
141
+ /** Keys a project-file edit may only tighten; see {@link aftLiveSecurityKeys}. */
142
+ securityKeys?: readonly LiveSecurityKey<C>[];
143
+ keys: readonly LiveConfigKey<C>[];
144
+ getConfig(): C;
145
+ setConfig(config: C): void;
146
+ /** Informational log line. */
147
+ log(message: string): void;
148
+ /** A config error, logged and shown to the user once per distinct message. */
149
+ reportError(message: string): void;
150
+ /** Skip the file watch; tests call `reload()` directly. */
151
+ watch?: boolean;
152
+ debounceMs?: number;
153
+ }
154
+ /** Handle for a running live config reload. */
155
+ export interface LiveConfigReload {
156
+ /** Read the files now and apply the live keys that changed. */
157
+ reload(): LiveConfigApply<unknown> | null;
158
+ stop(): void;
159
+ }
160
+ /** The log line a reload writes, in the same shape as the engine's. */
161
+ export declare function liveConfigReloadLogLine(applied: string[], deferred: string[]): string;
162
+ /**
163
+ * Watch the config files and keep `ctx.config` current for the live keys.
164
+ * An invalid, unreadable or deleted file keeps the last valid config: the
165
+ * error is reported and nothing else changes. Right after the watch starts
166
+ * the files are read once, so an edit made while the host was starting (after
167
+ * its config was loaded, before the watch existed) is not missed.
168
+ */
169
+ export declare function startLiveConfigReload<C>(options: LiveConfigReloadOptions<C>): LiveConfigReload;
170
+ //# sourceMappingURL=config-watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-watch.d.ts","sourceRoot":"","sources":["../src/config-watch.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Live reload of AFT config file edits in a host plugin.
3
+ *
4
+ * The plugin keeps its own copy of the resolved config (`ctx.config`) for the
5
+ * keys it enforces or uses itself, such as the path restriction pre-check and
6
+ * the bash wait limits. The engine applies its own keys from the same files
7
+ * separately, so this module only swaps the plugin's copy.
8
+ *
9
+ * Only keys that are safe to change under a running session are applied: the
10
+ * ones the plugin reads from `ctx.config` on every tool call. Keys that change
11
+ * the registered tools, their descriptions or the system text stay as loaded
12
+ * and apply at the next host restart; they are reported as deferred.
13
+ */
14
+ import { readFileSync, statSync, watch } from "node:fs";
15
+ import { basename, dirname } from "node:path";
16
+ /** How long a burst of file events must be quiet before the files are read. */
17
+ export const CONFIG_WATCH_DEBOUNCE_MS = 150;
18
+ /**
19
+ * Appended to a config error found by a live reload, in place of the
20
+ * "restart the host" note a startup config error carries: the plugin keeps
21
+ * running on the last valid configuration.
22
+ */
23
+ export const CONFIG_LIVE_KEEP_NOTE = "AFT keeps using the last valid configuration until the file is fixed.";
24
+ function pathKey(name) {
25
+ const segments = name.split(".");
26
+ return {
27
+ name,
28
+ read(config) {
29
+ let value = config;
30
+ for (const segment of segments) {
31
+ if (value === null || typeof value !== "object")
32
+ return undefined;
33
+ value = value[segment];
34
+ }
35
+ return value;
36
+ },
37
+ write(config, value) {
38
+ const set = (target, index) => {
39
+ const copy = target !== null && typeof target === "object" && !Array.isArray(target)
40
+ ? { ...target }
41
+ : {};
42
+ const segment = segments[index];
43
+ if (index === segments.length - 1) {
44
+ if (value === undefined)
45
+ delete copy[segment];
46
+ else
47
+ copy[segment] = value;
48
+ }
49
+ else {
50
+ copy[segment] = set(copy[segment], index + 1);
51
+ }
52
+ return copy;
53
+ };
54
+ return set(config, 0);
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * Bash keys are read through the host's `resolveBashConfig`, because `bash`
60
+ * may be a boolean, an object or the legacy `experimental.bash` block. A write
61
+ * turns `bash` into the equivalent object form, so every other bash setting
62
+ * (for example `compress` or `background`, which are not live) resolves to
63
+ * the same value as before.
64
+ */
65
+ function bashKey(name, resolveBash) {
66
+ return {
67
+ name: `bash.${name}`,
68
+ read: (config) => resolveBash(config)[name],
69
+ write(config, value) {
70
+ const resolved = { ...resolveBash(config), [name]: value };
71
+ for (const key of Object.keys(resolved)) {
72
+ if (resolved[key] === undefined)
73
+ delete resolved[key];
74
+ }
75
+ return { ...config, bash: resolved };
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * Every key the plugins read that a live reload applies. The engine-side
81
+ * list is `apply_live_config` in `crates/aft/src/config_live.rs`.
82
+ */
83
+ export function aftLiveConfigKeys(resolveBash) {
84
+ return [
85
+ pathKey("configure_warnings_delivery"),
86
+ pathKey("restrict_to_project_root"),
87
+ pathKey("inspect.diagnostics_timeout_ms"),
88
+ pathKey("inspect.tier2_idle_minutes"),
89
+ bashKey("foreground_wait_window_ms", resolveBash),
90
+ bashKey("host_fallback", resolveBash),
91
+ bashKey("subagent_background", resolveBash),
92
+ bashKey("watch_sync_max_ms", resolveBash),
93
+ ];
94
+ }
95
+ function sameValue(a, b) {
96
+ return JSON.stringify(a) === JSON.stringify(b);
97
+ }
98
+ function flattenLeaves(value, prefix, out) {
99
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
100
+ const entries = Object.entries(value);
101
+ if (entries.length === 0 && prefix)
102
+ out.set(prefix, "{}");
103
+ for (const [key, child] of entries) {
104
+ flattenLeaves(child, prefix ? `${prefix}.${key}` : key, out);
105
+ }
106
+ return;
107
+ }
108
+ if (prefix)
109
+ out.set(prefix, JSON.stringify(value));
110
+ }
111
+ /**
112
+ * Copy the live keys that differ from `current` out of `next`. `config` is a
113
+ * new object whenever something was applied, so a caller that captured the
114
+ * old one keeps a consistent snapshot. Other settings that differ from
115
+ * `baseline` (the config the plugin loaded at startup) are listed in
116
+ * `deferred` and not applied.
117
+ */
118
+ export function applyLiveConfigKeys(current, next, keys, baseline = current) {
119
+ let config = current;
120
+ const applied = [];
121
+ for (const key of keys) {
122
+ const value = key.read(next);
123
+ if (sameValue(key.read(current), value))
124
+ continue;
125
+ config = key.write(config, value);
126
+ applied.push(key.name);
127
+ }
128
+ const before = new Map();
129
+ const after = new Map();
130
+ flattenLeaves(baseline, "", before);
131
+ flattenLeaves(next, "", after);
132
+ const liveNames = keys.map((key) => key.name);
133
+ const isLive = (leaf) => liveNames.some((name) => leaf === name || leaf.startsWith(`${name}.`));
134
+ const deferred = new Set();
135
+ for (const leaf of new Set([...before.keys(), ...after.keys()])) {
136
+ if (before.get(leaf) === after.get(leaf) || isLive(leaf))
137
+ continue;
138
+ deferred.add(leaf);
139
+ }
140
+ return { config, applied, deferred: [...deferred].sort() };
141
+ }
142
+ function readTextOrNull(path) {
143
+ try {
144
+ return readFileSync(path, "utf8");
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ /** How often the watches are checked against the directories on disk. */
151
+ const CONFIG_WATCH_REVALIDATE_MS = 2_000;
152
+ /** How many times a rejected text is re-checked without a new event. */
153
+ const CONFIG_WATCH_REJECTED_RETRIES = 3;
154
+ /** Longest a stream of events may postpone a check. */
155
+ const CONFIG_WATCH_MAX_DELAY_MS = 1_000;
156
+ /**
157
+ * A directory's identity, which changes when the directory is replaced under
158
+ * the same name.
159
+ *
160
+ * - Everywhere: device and inode. On Windows Node reports the volume serial
161
+ * number and the 64-bit file index there; `bigint` keeps the index exact.
162
+ * - Not on Windows: also the birth time, because ext4 hands a freed inode
163
+ * number straight to the next directory created, so a directory deleted and
164
+ * recreated keeps its inode number while inotify's watch stays on the
165
+ * deleted one. On Windows the birth time is left out: NTFS file-name
166
+ * tunneling gives a name reused within about 15 s the creation time of the
167
+ * entry that last had it, so it carries no information there.
168
+ *
169
+ * Where a filesystem records no birth time it is 0 and the inode decides; the
170
+ * delete event (see `watchAftConfigFiles`) re-arms the watch in that case.
171
+ */
172
+ function identityOf(dir) {
173
+ try {
174
+ const stat = statSync(dir, { bigint: true });
175
+ const base = `${stat.dev}:${stat.ino}`;
176
+ return process.platform === "win32" ? base : `${base}:${stat.birthtimeNs}`;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * The directory to watch for `file`: its own directory, or while that does
184
+ * not exist, the nearest existing ancestor.
185
+ */
186
+ function watchDirFor(file) {
187
+ let dir = dirname(file);
188
+ while (dir !== dirname(dir) && identityOf(dir) === null)
189
+ dir = dirname(dir);
190
+ return dir;
191
+ }
192
+ /**
193
+ * Watch config files the way editors save them: the parent directory is
194
+ * watched, because an editor replaces the file by renaming a temporary
195
+ * sibling over it. A directory that does not exist yet is watched through its
196
+ * nearest existing ancestor until it appears, and a watch whose directory was
197
+ * replaced or failed is re-armed. Every event in a watched directory wakes
198
+ * the check (platforms may coalesce several changes into one event naming a
199
+ * different entry), and a steady stream of events delays it at most
200
+ * {@link CONFIG_WATCH_MAX_DELAY_MS}. `onChange` runs only when a file's text
201
+ * actually differs from what was last seen. Returns a function that stops
202
+ * every watch.
203
+ */
204
+ export function watchAftConfigFiles(options) {
205
+ const debounceMs = options.debounceMs ?? CONFIG_WATCH_DEBOUNCE_MS;
206
+ // `undefined` for a path means "not known", which differs from every text.
207
+ const lastSeen = new Map();
208
+ for (const path of options.paths) {
209
+ if (options.initialTexts)
210
+ lastSeen.set(path, options.initialTexts[path] ?? null);
211
+ else
212
+ lastSeen.set(path, options.checkAtStart ? undefined : readTextOrNull(path));
213
+ }
214
+ let timer = null;
215
+ let firstPendingAt = null;
216
+ let stopped = false;
217
+ const watchers = new Map();
218
+ const watchImpl = options.watchImpl ?? watch;
219
+ let rejectedRetries = 0;
220
+ const check = () => {
221
+ timer = null;
222
+ firstPendingAt = null;
223
+ if (stopped)
224
+ return;
225
+ revalidate();
226
+ const seen = new Map();
227
+ for (const path of options.paths) {
228
+ const text = readTextOrNull(path);
229
+ if (text !== lastSeen.get(path))
230
+ seen.set(path, text);
231
+ }
232
+ if (seen.size === 0)
233
+ return;
234
+ const outcome = options.onChange();
235
+ if (outcome === false) {
236
+ // Not accepted: keep the old texts as seen so the next event retries,
237
+ // and retry on our own a few times in case no further event comes (an
238
+ // editor that finished its save before this check read the file).
239
+ if (rejectedRetries < CONFIG_WATCH_REJECTED_RETRIES) {
240
+ rejectedRetries += 1;
241
+ retryTimer = setTimeout(check, CONFIG_WATCH_MAX_DELAY_MS);
242
+ retryTimer.unref?.();
243
+ }
244
+ return;
245
+ }
246
+ rejectedRetries = 0;
247
+ if (outcome instanceof Map) {
248
+ for (const path of options.paths)
249
+ lastSeen.set(path, outcome.get(path) ?? null);
250
+ }
251
+ else {
252
+ for (const [path, text] of seen)
253
+ lastSeen.set(path, text);
254
+ }
255
+ };
256
+ let retryTimer = null;
257
+ const schedule = () => {
258
+ if (stopped)
259
+ return;
260
+ const now = Date.now();
261
+ firstPendingAt ??= now;
262
+ if (timer)
263
+ clearTimeout(timer);
264
+ const delay = Math.max(0, Math.min(debounceMs, firstPendingAt + CONFIG_WATCH_MAX_DELAY_MS - now));
265
+ timer = setTimeout(check, delay);
266
+ timer.unref?.();
267
+ };
268
+ /** Bring the watches in line with the directories on disk. */
269
+ function revalidate() {
270
+ const wanted = new Set(options.paths.map(watchDirFor));
271
+ let moved = false;
272
+ for (const [dir, entry] of watchers) {
273
+ // A directory that is no longer wanted (its child appeared) or that was
274
+ // replaced under the watch is dropped and, if still wanted, re-armed.
275
+ if (!wanted.has(dir) || entry.stale || identityOf(dir) !== entry.identity) {
276
+ entry.watcher.close();
277
+ watchers.delete(dir);
278
+ moved = true;
279
+ }
280
+ }
281
+ for (const dir of wanted) {
282
+ if (watchers.has(dir))
283
+ continue;
284
+ try {
285
+ // Read the identity first and confirm it after: a directory replaced
286
+ // in between would leave the watch on the old one under the new
287
+ // one's identity, and it would never be re-armed.
288
+ const identity = identityOf(dir);
289
+ options.beforeWatchForTest?.(dir);
290
+ const name = basename(dir);
291
+ const watcher = watchImpl(dir, { persistent: false }, (event, filename) => {
292
+ // A `rename` naming the watched directory itself means the
293
+ // directory was deleted or moved: the watch follows the old one,
294
+ // so the next check re-arms it on whatever now has this path.
295
+ if (event === "rename" && filename === name) {
296
+ const current = watchers.get(dir);
297
+ if (current && current.watcher === watcher)
298
+ current.stale = true;
299
+ }
300
+ schedule();
301
+ });
302
+ if (identityOf(dir) !== identity) {
303
+ watcher.close();
304
+ moved = true;
305
+ continue;
306
+ }
307
+ const entry = { watcher, identity, stale: false };
308
+ entry.watcher.on("error", () => {
309
+ entry.watcher.close();
310
+ if (watchers.get(dir) === entry)
311
+ watchers.delete(dir);
312
+ // Re-arm on the next check rather than going silent.
313
+ schedule();
314
+ });
315
+ watchers.set(dir, entry);
316
+ moved = true;
317
+ }
318
+ catch {
319
+ // The directory vanished between the check and the watch; the next
320
+ // revalidation finds its nearest existing ancestor.
321
+ }
322
+ }
323
+ return moved;
324
+ }
325
+ revalidate();
326
+ if (options.checkAtStart)
327
+ check();
328
+ // Some platforms stop reporting on a deleted or replaced directory without
329
+ // an error; a periodic check re-arms such a watch. Moving a watch (for
330
+ // example once `.cortexkit/` is created) may reveal a file, so it checks.
331
+ const revalidateTimer = setInterval(() => {
332
+ if (!stopped && revalidate())
333
+ schedule();
334
+ }, CONFIG_WATCH_REVALIDATE_MS);
335
+ revalidateTimer.unref?.();
336
+ return () => {
337
+ stopped = true;
338
+ if (timer)
339
+ clearTimeout(timer);
340
+ if (retryTimer)
341
+ clearTimeout(retryTimer);
342
+ clearInterval(revalidateTimer);
343
+ for (const entry of watchers.values())
344
+ entry.watcher.close();
345
+ watchers.clear();
346
+ };
347
+ }
348
+ /** The plugin-read security keys: the path restriction and host fallback. */
349
+ export function aftLiveSecurityKeys(keys) {
350
+ const byName = new Map(keys.map((key) => [key.name, key]));
351
+ const pick = (name) => {
352
+ const key = byName.get(name);
353
+ if (!key)
354
+ throw new Error(`unknown live config key ${name}`);
355
+ return key;
356
+ };
357
+ return [
358
+ {
359
+ key: pick("restrict_to_project_root"),
360
+ loosens: (current, next) => current === true && next !== true,
361
+ },
362
+ {
363
+ key: pick("bash.host_fallback"),
364
+ loosens: (current, next) => current !== true && next === true,
365
+ },
366
+ ];
367
+ }
368
+ /** The log line a reload writes, in the same shape as the engine's. */
369
+ export function liveConfigReloadLogLine(applied, deferred) {
370
+ let line = "config reload";
371
+ if (applied.length > 0)
372
+ line += ` applied=[${applied.join(",")}]`;
373
+ if (deferred.length > 0) {
374
+ line += ` deferred=[${deferred.join(",")}] (deferred keys apply on next connect/restart)`;
375
+ }
376
+ return line;
377
+ }
378
+ /**
379
+ * Watch the config files and keep `ctx.config` current for the live keys.
380
+ * An invalid, unreadable or deleted file keeps the last valid config: the
381
+ * error is reported and nothing else changes. Right after the watch starts
382
+ * the files are read once, so an edit made while the host was starting (after
383
+ * its config was loaded, before the watch existed) is not missed.
384
+ */
385
+ export function startLiveConfigReload(options) {
386
+ const baseline = options.getConfig();
387
+ let lastError = null;
388
+ // The files the last accepted load read. A load that no longer reads one of
389
+ // them keeps the last valid config rather than resolving the file as empty:
390
+ // a security setting must not loosen because a file vanished mid-save or
391
+ // was deleted by mistake. The next host restart applies a real deletion.
392
+ let acceptedSources = new Set(options.initialSources);
393
+ // The project text the published security values rest on. A project edit
394
+ // that would loosen one of them is held until the next restart, and this
395
+ // stays at the older text so later reloads keep holding it.
396
+ let acceptedProjectText = options.initialProjectText ?? null;
397
+ // The texts the last accepted load read, handed to the watch so it records
398
+ // what was applied rather than what it happened to read.
399
+ let acceptedTexts;
400
+ const reload = () => {
401
+ let loaded = options.load();
402
+ if (loaded.ok) {
403
+ const read = new Set(loaded.sources);
404
+ const deleted = [...acceptedSources].find((path) => !read.has(path));
405
+ if (deleted !== undefined) {
406
+ loaded = { ok: false, message: `AFT config at ${deleted} was deleted` };
407
+ }
408
+ else {
409
+ acceptedSources = read;
410
+ }
411
+ }
412
+ if (!loaded.ok) {
413
+ const message = `${loaded.message.trim().replace(/[.!?]?$/, ".")} ${CONFIG_LIVE_KEEP_NOTE}`;
414
+ if (message !== lastError) {
415
+ lastError = message;
416
+ options.reportError(message);
417
+ }
418
+ return null;
419
+ }
420
+ lastError = null;
421
+ acceptedTexts = loaded.texts;
422
+ const current = options.getConfig();
423
+ let next = loaded.config;
424
+ const held = [];
425
+ const projectText = loaded.projectText ?? null;
426
+ if (loaded.projectText !== undefined && projectText !== acceptedProjectText) {
427
+ for (const security of options.securityKeys ?? []) {
428
+ const now = security.key.read(current);
429
+ if (security.loosens(now, security.key.read(next))) {
430
+ next = security.key.write(next, now);
431
+ held.push(security.key.name);
432
+ }
433
+ }
434
+ if (held.length === 0)
435
+ acceptedProjectText = projectText;
436
+ }
437
+ const result = applyLiveConfigKeys(current, next, options.keys, baseline);
438
+ if (result.applied.length > 0)
439
+ options.setConfig(result.config);
440
+ if (result.applied.length > 0 || result.deferred.length > 0 || held.length > 0) {
441
+ let line = liveConfigReloadLogLine(result.applied, result.deferred);
442
+ if (held.length > 0) {
443
+ line += ` held=[${held.join(",")}] (not loosened while the project file differs from the one the published values came from; the next restart applies them)`;
444
+ }
445
+ options.log(line);
446
+ }
447
+ return { ...result, held };
448
+ };
449
+ const safeReload = () => {
450
+ try {
451
+ acceptedTexts = undefined;
452
+ if (reload() === null)
453
+ return false;
454
+ const texts = acceptedTexts;
455
+ if (!texts)
456
+ return true;
457
+ return new Map(options.paths.map((path) => [path, texts[path] ?? null]));
458
+ }
459
+ catch (err) {
460
+ options.reportError(`AFT config reload failed: ${err instanceof Error ? err.message : String(err)}`);
461
+ return false;
462
+ }
463
+ };
464
+ if (options.watch === false) {
465
+ return { reload: reload, stop: () => { } };
466
+ }
467
+ // The first check runs as soon as the watch is attached, through the same
468
+ // accepted-text and retry path as every later one, so an edit made while
469
+ // the host was starting is applied and a transient rejection is retried.
470
+ const stop = watchAftConfigFiles({
471
+ paths: options.paths,
472
+ debounceMs: options.debounceMs,
473
+ onChange: safeReload,
474
+ initialTexts: options.initialTexts,
475
+ checkAtStart: true,
476
+ });
477
+ return { reload: reload, stop };
478
+ }
479
+ //# sourceMappingURL=config-watch.js.map