@massu/core 1.3.0 → 1.4.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/commands/README.md +23 -11
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-scaffold-page.swift.md +10 -10
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/dist/cli.js +9914 -4133
- package/dist/hooks/auto-learning-pipeline.js +45 -2
- package/dist/hooks/classify-failure.js +45 -2
- package/dist/hooks/cost-tracker.js +45 -2
- package/dist/hooks/fix-detector.js +45 -2
- package/dist/hooks/incident-pipeline.js +45 -2
- package/dist/hooks/post-edit-context.js +45 -2
- package/dist/hooks/post-tool-use.js +45 -2
- package/dist/hooks/pre-compact.js +45 -2
- package/dist/hooks/pre-delete-check.js +45 -2
- package/dist/hooks/quality-event.js +45 -2
- package/dist/hooks/rule-enforcement-pipeline.js +45 -2
- package/dist/hooks/session-end.js +45 -2
- package/dist/hooks/session-start.js +4790 -406
- package/dist/hooks/user-prompt.js +45 -2
- package/package.json +13 -4
- package/src/cli.ts +22 -2
- package/src/commands/config-refresh.ts +91 -23
- package/src/commands/init.ts +131 -24
- package/src/commands/install-commands.ts +142 -26
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/template-engine.ts +260 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +71 -0
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +467 -0
- package/src/detect/adapters/types.ts +173 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +98 -0
- package/src/lsp/client.ts +776 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `massu watch` daemon main loop.
|
|
6
|
+
*
|
|
7
|
+
* Combines Layer B (chokidar watcher + state persistence) and Layer C
|
|
8
|
+
* (quiescence detector: debounce, storm detection, lockfile + git
|
|
9
|
+
* mid-write hard-stops) into a single foreground process. The CLI
|
|
10
|
+
* wrapper in commands/watch.ts spawns this under claude-bg so it's
|
|
11
|
+
* registered for lifecycle reaping.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as chokidar from 'chokidar';
|
|
15
|
+
import { resetConfig, getConfig } from '../config.ts';
|
|
16
|
+
import { gitMidOperation, lockfileMidWrite } from './lockfile-detector.ts';
|
|
17
|
+
import { deriveWatchGlobs, enforceWatchSurfaceCap, WatchSurfaceTooLargeError } from './paths.ts';
|
|
18
|
+
import { updateState } from './state.ts';
|
|
19
|
+
|
|
20
|
+
export const STORM_WINDOW_MS = 1_000;
|
|
21
|
+
export const DEEP_STORM_WINDOW_MS = 10_000;
|
|
22
|
+
export const STORM_WAIT_MS = 30_000;
|
|
23
|
+
export const DEEP_STORM_WAIT_MS = 120_000;
|
|
24
|
+
export const TICK_INTERVAL_MS = 10_000;
|
|
25
|
+
export const TICK_GAP_THRESHOLD_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
export interface DaemonHooks {
|
|
28
|
+
/** Called after quiescence + hard-stops pass. Implementations refresh + install. */
|
|
29
|
+
onQuiescent: () => Promise<void> | void;
|
|
30
|
+
/** Optional override for current time, used in tests. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
/** Stderr writer. Defaults to process.stderr.write. */
|
|
33
|
+
writeStderr?: (s: string) => void;
|
|
34
|
+
/** When true, skip the chokidar setup (used by tests that drive events manually). */
|
|
35
|
+
noWatcher?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DaemonHandle {
|
|
39
|
+
/** Synthetic event ingest — used by tests; in prod, chokidar drives this. */
|
|
40
|
+
pushEvent: (path: string) => void;
|
|
41
|
+
/** Force the quiescence timer to fire NOW (for `--apply-now`). */
|
|
42
|
+
flushNow: () => Promise<void>;
|
|
43
|
+
/** Stop the watcher and clear timers. */
|
|
44
|
+
stop: () => Promise<void>;
|
|
45
|
+
/** Explicit reconciliation pass — used after sleep/wake gap. */
|
|
46
|
+
forceReconciliation: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface QuiescenceContext {
|
|
50
|
+
/** Pending event timestamps within the recent storm windows. */
|
|
51
|
+
recent: number[];
|
|
52
|
+
/** Pending refresh timer. */
|
|
53
|
+
debounceTimer: NodeJS.Timeout | null;
|
|
54
|
+
/** Hard-timeout (5 min) timer that fires even when events don't stop. */
|
|
55
|
+
hardTimeoutAt: number | null;
|
|
56
|
+
/** When in storm/deep-storm, do not schedule another refresh until this ts. */
|
|
57
|
+
stormCooldownUntil: number;
|
|
58
|
+
/** Last setInterval tick epoch — used by tick-gap heuristic. */
|
|
59
|
+
lastTickAt: number;
|
|
60
|
+
/** Set after a sleep/wake gap is detected, before the reconciliation runs. */
|
|
61
|
+
reconciliationPending: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DaemonConfig {
|
|
65
|
+
projectRoot: string;
|
|
66
|
+
debounceMs: number;
|
|
67
|
+
stormThreshold: number;
|
|
68
|
+
deepStormThreshold: number;
|
|
69
|
+
hardTimeoutMs: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readDaemonConfig(projectRoot: string): DaemonConfig {
|
|
73
|
+
// resetConfig() is the caller's responsibility (we want to read the
|
|
74
|
+
// freshest YAML after every refresh cycle).
|
|
75
|
+
const cfg = getConfig();
|
|
76
|
+
const w = (cfg.watch as Record<string, unknown> | undefined) ?? {};
|
|
77
|
+
const num = (k: string, fallback: number): number => {
|
|
78
|
+
const v = w[k];
|
|
79
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
projectRoot,
|
|
83
|
+
debounceMs: num('debounce_ms', 3_000),
|
|
84
|
+
stormThreshold: num('storm_threshold', 50),
|
|
85
|
+
deepStormThreshold: num('deep_storm_threshold', 500),
|
|
86
|
+
hardTimeoutMs: num('hard_timeout_ms', 300_000),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Start the daemon. Returns a handle for graceful shutdown / test injection.
|
|
92
|
+
*
|
|
93
|
+
* In production: chokidar drives `pushEvent` via the file-watcher.
|
|
94
|
+
* In tests: pass `noWatcher: true` and call `pushEvent` directly.
|
|
95
|
+
*/
|
|
96
|
+
export async function startDaemon(projectRoot: string, hooks: DaemonHooks): Promise<DaemonHandle> {
|
|
97
|
+
const now = hooks.now ?? Date.now;
|
|
98
|
+
const writeStderr = hooks.writeStderr ?? ((s: string) => { process.stderr.write(s); });
|
|
99
|
+
|
|
100
|
+
const cfg = readDaemonConfig(projectRoot);
|
|
101
|
+
const ctx: QuiescenceContext = {
|
|
102
|
+
recent: [],
|
|
103
|
+
debounceTimer: null,
|
|
104
|
+
hardTimeoutAt: null,
|
|
105
|
+
stormCooldownUntil: 0,
|
|
106
|
+
lastTickAt: now(),
|
|
107
|
+
reconciliationPending: false,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let watcher: chokidar.FSWatcher | null = null;
|
|
111
|
+
let tickTimer: NodeJS.Timeout | null = null;
|
|
112
|
+
let stopped = false;
|
|
113
|
+
// Mutex to prevent overlapping reruns of the quiescence callback.
|
|
114
|
+
let runningRefresh = false;
|
|
115
|
+
|
|
116
|
+
function clearDebounce(): void {
|
|
117
|
+
if (ctx.debounceTimer) {
|
|
118
|
+
clearTimeout(ctx.debounceTimer);
|
|
119
|
+
ctx.debounceTimer = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pruneRecent(t: number): void {
|
|
124
|
+
const cutoff = t - DEEP_STORM_WINDOW_MS;
|
|
125
|
+
if (ctx.recent.length === 0 || ctx.recent[0] >= cutoff) return;
|
|
126
|
+
// Single-pass filter beats repeated O(n) Array.shift() calls when many
|
|
127
|
+
// events fall outside the window at once (iter-9 simplify finding E2).
|
|
128
|
+
ctx.recent = ctx.recent.filter((x) => x >= cutoff);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectStorm(t: number): 'normal' | 'storm' | 'deep_storm' {
|
|
132
|
+
pruneRecent(t);
|
|
133
|
+
const lastSecond = ctx.recent.filter((x) => t - x <= STORM_WINDOW_MS).length;
|
|
134
|
+
const lastTen = ctx.recent.length;
|
|
135
|
+
if (lastTen > cfg.deepStormThreshold) return 'deep_storm';
|
|
136
|
+
if (lastSecond > cfg.stormThreshold) return 'storm';
|
|
137
|
+
return 'normal';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fireRefresh(): Promise<void> {
|
|
141
|
+
if (runningRefresh) {
|
|
142
|
+
// Observability: surface the skip so users investigating "why didn't a
|
|
143
|
+
// refresh fire?" can see it in stderr instead of silent suppression.
|
|
144
|
+
writeStderr('[massu] refresh skipped (previous refresh still running)\n');
|
|
145
|
+
// Iter-2 correctness fix: don't drop the deferred refresh on the floor.
|
|
146
|
+
// Re-arm the debounce so the watcher will retry after the current
|
|
147
|
+
// refresh resolves. Without this, a fresh-event-burst arriving while a
|
|
148
|
+
// previous refresh is in flight would consume the debounce timer and
|
|
149
|
+
// never fire — file changes would be silently lost until the NEXT
|
|
150
|
+
// unrelated event woke the daemon back up.
|
|
151
|
+
scheduleDebounce(cfg.debounceMs);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
runningRefresh = true;
|
|
155
|
+
try {
|
|
156
|
+
// Hard-stops (G3-A12 of plan + Layer C semantics):
|
|
157
|
+
if (gitMidOperation(cfg.projectRoot)) {
|
|
158
|
+
writeStderr('[massu] git operation in progress (.git/MERGE_HEAD or REBASE_HEAD); skipping refresh\n');
|
|
159
|
+
// Iter-3 (third pass) G3-iter3-E6: re-arm the debounce so the
|
|
160
|
+
// watcher will retry once the merge/rebase completes. Otherwise,
|
|
161
|
+
// if zero file events fire AFTER the git operation finishes (e.g.,
|
|
162
|
+
// user resolved conflicts inside the editor and the editor's
|
|
163
|
+
// chokidar events all hit during the op), the refresh would never
|
|
164
|
+
// re-trigger until an unrelated event woke the daemon.
|
|
165
|
+
scheduleDebounce(cfg.debounceMs);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Lockfile mid-write — defer one more debounce cycle.
|
|
169
|
+
if (lockfileMidWrite(cfg.projectRoot, now())) {
|
|
170
|
+
writeStderr('[massu] lockfile mid-write detected; deferring refresh by debounce\n');
|
|
171
|
+
scheduleDebounce(cfg.debounceMs);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await hooks.onQuiescent();
|
|
176
|
+
ctx.recent = [];
|
|
177
|
+
ctx.hardTimeoutAt = null;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
+
writeStderr(`[massu] refresh error: ${msg}\n`);
|
|
181
|
+
try {
|
|
182
|
+
updateState(cfg.projectRoot, { lastError: msg });
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort
|
|
185
|
+
}
|
|
186
|
+
// Iter-5 correctness fix: re-arm the debounce so a transient error
|
|
187
|
+
// (e.g., runDetection throws on a fs blip / NFS hiccup) does not
|
|
188
|
+
// permanently strand the daemon waiting for the next file event.
|
|
189
|
+
// Mirrors the git-mid-op (G3-iter3-E6) and lockfile-mid-write paths
|
|
190
|
+
// above. Without this re-arm, a one-shot detection error during a
|
|
191
|
+
// quiet period would silently suppress refreshes until a wholly
|
|
192
|
+
// unrelated event re-armed the debounce timer.
|
|
193
|
+
scheduleDebounce(cfg.debounceMs);
|
|
194
|
+
} finally {
|
|
195
|
+
runningRefresh = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
function scheduleDebounce(delayMs: number): void {
|
|
201
|
+
// Iter-6 fix: do not schedule new refreshes after stop(). Otherwise an
|
|
202
|
+
// in-flight refresh that hits a re-arm path (transient error, lockfile
|
|
203
|
+
// mid-write, etc.) during shutdown would create a setTimeout that fires
|
|
204
|
+
// AFTER the daemon was told to stop — leaking a timer and (worse) firing
|
|
205
|
+
// a refresh against a torn-down state.
|
|
206
|
+
if (stopped) return;
|
|
207
|
+
clearDebounce();
|
|
208
|
+
ctx.debounceTimer = setTimeout(() => {
|
|
209
|
+
ctx.debounceTimer = null;
|
|
210
|
+
void fireRefresh();
|
|
211
|
+
}, delayMs);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pushEvent(_path: string): void {
|
|
215
|
+
if (stopped) return;
|
|
216
|
+
const t = now();
|
|
217
|
+
ctx.recent.push(t);
|
|
218
|
+
|
|
219
|
+
// Hard-timeout: from the FIRST event in the current burst.
|
|
220
|
+
if (ctx.hardTimeoutAt === null) {
|
|
221
|
+
ctx.hardTimeoutAt = t + cfg.hardTimeoutMs;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Already in storm cooldown — do not reschedule, just accumulate.
|
|
225
|
+
if (t < ctx.stormCooldownUntil) return;
|
|
226
|
+
|
|
227
|
+
const intensity = detectStorm(t);
|
|
228
|
+
let delay = cfg.debounceMs;
|
|
229
|
+
if (intensity === 'storm') {
|
|
230
|
+
delay = STORM_WAIT_MS;
|
|
231
|
+
ctx.stormCooldownUntil = t + STORM_WAIT_MS;
|
|
232
|
+
} else if (intensity === 'deep_storm') {
|
|
233
|
+
delay = DEEP_STORM_WAIT_MS;
|
|
234
|
+
ctx.stormCooldownUntil = t + DEEP_STORM_WAIT_MS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Hard-timeout floor: if we've been debouncing past the budget, fire now.
|
|
238
|
+
if (ctx.hardTimeoutAt !== null && t >= ctx.hardTimeoutAt) {
|
|
239
|
+
clearDebounce();
|
|
240
|
+
void fireRefresh();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
scheduleDebounce(delay);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function forceReconciliation(): Promise<void> {
|
|
248
|
+
ctx.reconciliationPending = false;
|
|
249
|
+
clearDebounce();
|
|
250
|
+
await fireRefresh();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function flushNow(): Promise<void> {
|
|
254
|
+
clearDebounce();
|
|
255
|
+
ctx.stormCooldownUntil = 0;
|
|
256
|
+
await fireRefresh();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function stop(): Promise<void> {
|
|
260
|
+
stopped = true;
|
|
261
|
+
clearDebounce();
|
|
262
|
+
if (tickTimer) {
|
|
263
|
+
clearInterval(tickTimer);
|
|
264
|
+
tickTimer = null;
|
|
265
|
+
}
|
|
266
|
+
if (watcher) {
|
|
267
|
+
await watcher.close();
|
|
268
|
+
watcher = null;
|
|
269
|
+
}
|
|
270
|
+
// Iter-6 SIGINT graceful-shutdown: ideally we would `await` an in-flight
|
|
271
|
+
// fireRefresh here so SIGINT/SIGTERM doesn't cut a refresh mid-write.
|
|
272
|
+
// However, three forces work in the OPPOSITE direction:
|
|
273
|
+
// 1. Every file op the refresh issues is already atomic-rename-safe:
|
|
274
|
+
// `runConfigRefresh` writes `<path>.tmp` then `renameSync`;
|
|
275
|
+
// `installAll` writes `<path>.tmp` then `renameSync`; `updateState`
|
|
276
|
+
// uses `writeStateAtomic` (tmp + fsync + rename); `appendRefreshLog`
|
|
277
|
+
// is JSONL-append (partial trailing line tolerated by readers).
|
|
278
|
+
// 2. If a refresh is interrupted partway through `installAll`, the next
|
|
279
|
+
// run completes the remainder — that's by design.
|
|
280
|
+
// 3. The `installAll.lock` (proper-lockfile) ensures another caller
|
|
281
|
+
// arriving during a partial-completion can't race against the
|
|
282
|
+
// original writer's process.
|
|
283
|
+
// Plus: a Promise-tracking implementation chained to fireRefresh adds
|
|
284
|
+
// microtasks that interact poorly with vitest's `advanceTimersByTimeAsync`
|
|
285
|
+
// in tests where mock `onQuiescent` returns a forever-pending promise
|
|
286
|
+
// (the iter-2 deferred-fire test breaks). A polling implementation hangs
|
|
287
|
+
// when fake timers freeze `Date.now()`. Both approaches were tried in
|
|
288
|
+
// iter-6 and rejected.
|
|
289
|
+
// Decision: rely on per-file atomic-rename + the install-lock. Document
|
|
290
|
+
// the residual SIGINT semantics in the spec doc so users understand a
|
|
291
|
+
// mid-refresh kill leaves a partially-applied .claude/ that the next
|
|
292
|
+
// run completes. The `stopped` guard in `scheduleDebounce` and
|
|
293
|
+
// `pushEvent` still prevents NEW refreshes from being scheduled after
|
|
294
|
+
// shutdown, which is the leak-prevention concern that IS reachable.
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function tick(): void {
|
|
298
|
+
if (stopped) return;
|
|
299
|
+
const t = now();
|
|
300
|
+
const gap = t - ctx.lastTickAt;
|
|
301
|
+
ctx.lastTickAt = t;
|
|
302
|
+
try {
|
|
303
|
+
updateState(cfg.projectRoot, { tickedAt: new Date(t).toISOString() });
|
|
304
|
+
} catch {
|
|
305
|
+
// best-effort — never let tick failure crash the daemon.
|
|
306
|
+
}
|
|
307
|
+
if (gap > TICK_GAP_THRESHOLD_MS && !ctx.reconciliationPending) {
|
|
308
|
+
ctx.reconciliationPending = true;
|
|
309
|
+
writeStderr(`[massu] tick gap detected (${gap}ms, likely sleep/wake); reconciling\n`);
|
|
310
|
+
void forceReconciliation();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!hooks.noWatcher) {
|
|
315
|
+
const cfgYaml = getConfig();
|
|
316
|
+
const globs = deriveWatchGlobs(cfgYaml);
|
|
317
|
+
if (globs.usedFallback) {
|
|
318
|
+
writeStderr(`[massu] watching default globs (paths.*_source unset): ${globs.watch.join(', ')}\n`);
|
|
319
|
+
}
|
|
320
|
+
if (globs.rootWatchDetected) {
|
|
321
|
+
writeStderr(
|
|
322
|
+
`[massu] root sentinel ('.', '**', etc.) detected in source_dirs — ` +
|
|
323
|
+
`effective scope is now 'full'. Surface cap still applies (set ` +
|
|
324
|
+
`watch.paths_full_root_opt_in: true to override).\n`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Plan 3a hotfix 2026-05-02: preflight surface cap. Refuses to start
|
|
329
|
+
// (throws WatchSurfaceTooLargeError) if the configured globs would
|
|
330
|
+
// monitor more files than `watch.max_watched_files` AND the user has
|
|
331
|
+
// not set `watch.paths_full_root_opt_in: true`. Prevents the misconfig
|
|
332
|
+
// pattern that produced 30-100% sustained CPU on a large monorepo.
|
|
333
|
+
const cap = cfgYaml.watch?.max_watched_files ?? 10_000;
|
|
334
|
+
const optedIn = cfgYaml.watch?.paths_full_root_opt_in ?? false;
|
|
335
|
+
const t0 = now();
|
|
336
|
+
const surfaceCount = await enforceWatchSurfaceCap(globs, cfg.projectRoot, cap, optedIn);
|
|
337
|
+
const surfaceMs = now() - t0;
|
|
338
|
+
const countLabel = surfaceCount === Infinity ? `>${cap}` : String(surfaceCount);
|
|
339
|
+
writeStderr(
|
|
340
|
+
`[massu] watch surface: ${countLabel} files (cap ${cap}, ` +
|
|
341
|
+
`opted-in: ${optedIn}, scan ${surfaceMs}ms, scope: ${globs.effectiveScope})\n`
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
watcher = chokidar.watch(globs.watch, {
|
|
345
|
+
cwd: cfg.projectRoot,
|
|
346
|
+
ignored: globs.ignore,
|
|
347
|
+
ignoreInitial: true,
|
|
348
|
+
persistent: true,
|
|
349
|
+
awaitWriteFinish: false,
|
|
350
|
+
});
|
|
351
|
+
watcher.on('all', (_event: string, path: string) => pushEvent(path));
|
|
352
|
+
watcher.on('error', (err: unknown) => {
|
|
353
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
354
|
+
writeStderr(`[massu] chokidar error: ${msg}\n`);
|
|
355
|
+
// Persist so `massu watch --status` surfaces it; never let a state
|
|
356
|
+
// write throw out of the error handler (best-effort).
|
|
357
|
+
try {
|
|
358
|
+
updateState(cfg.projectRoot, { lastError: `chokidar: ${msg}` });
|
|
359
|
+
} catch {
|
|
360
|
+
// best-effort
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Reset cached config so Layer-B picks up watch.* tunables changed at runtime.
|
|
366
|
+
resetConfig();
|
|
367
|
+
|
|
368
|
+
// Persist startup state so refresh-log + status subcommands can read it.
|
|
369
|
+
try {
|
|
370
|
+
updateState(cfg.projectRoot, {
|
|
371
|
+
daemonPid: process.pid,
|
|
372
|
+
startedAt: new Date(now()).toISOString(),
|
|
373
|
+
tickedAt: new Date(now()).toISOString(),
|
|
374
|
+
lastError: null,
|
|
375
|
+
});
|
|
376
|
+
} catch {
|
|
377
|
+
// best-effort
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
tickTimer = setInterval(tick, TICK_INTERVAL_MS);
|
|
381
|
+
// Don't keep the event loop alive solely for this timer.
|
|
382
|
+
if (typeof tickTimer.unref === 'function') tickTimer.unref();
|
|
383
|
+
|
|
384
|
+
return { pushEvent, flushNow, stop, forceReconciliation };
|
|
385
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lockfile mid-write detector.
|
|
6
|
+
*
|
|
7
|
+
* If a `*.lock` file's mtime has shifted within the last LOCKFILE_WINDOW_MS,
|
|
8
|
+
* a writer is still active and the watcher should defer applying refresh
|
|
9
|
+
* for another debounce cycle.
|
|
10
|
+
*
|
|
11
|
+
* This is the canonical "wait for writer to settle" hook (chokidar's
|
|
12
|
+
* `awaitWriteFinish` is intentionally disabled — see watcher spec §2).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync } from 'fs';
|
|
16
|
+
import { resolve } from 'path';
|
|
17
|
+
|
|
18
|
+
export const LOCKFILE_WINDOW_MS = 500;
|
|
19
|
+
|
|
20
|
+
export const KNOWN_LOCKFILES = [
|
|
21
|
+
'package-lock.json',
|
|
22
|
+
'yarn.lock',
|
|
23
|
+
'pnpm-lock.yaml',
|
|
24
|
+
'bun.lockb',
|
|
25
|
+
'poetry.lock',
|
|
26
|
+
'Pipfile.lock',
|
|
27
|
+
'uv.lock',
|
|
28
|
+
'Cargo.lock',
|
|
29
|
+
'composer.lock',
|
|
30
|
+
'Gemfile.lock',
|
|
31
|
+
'mix.lock',
|
|
32
|
+
'go.sum',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns true when at least one known lockfile under projectRoot has
|
|
37
|
+
* been modified within `windowMs` of `now`. Used by the quiescence
|
|
38
|
+
* detector to extend the debounce when a package manager is mid-write.
|
|
39
|
+
*/
|
|
40
|
+
export function lockfileMidWrite(projectRoot: string, now = Date.now(), windowMs = LOCKFILE_WINDOW_MS): boolean {
|
|
41
|
+
for (const lf of KNOWN_LOCKFILES) {
|
|
42
|
+
const p = resolve(projectRoot, lf);
|
|
43
|
+
if (!existsSync(p)) continue;
|
|
44
|
+
try {
|
|
45
|
+
const stat = statSync(p);
|
|
46
|
+
const delta = now - stat.mtimeMs;
|
|
47
|
+
if (delta >= 0 && delta < windowMs) return true;
|
|
48
|
+
} catch {
|
|
49
|
+
// Race with rename; treat as not-mid-write. Next debounce cycle re-checks.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns true when a git operation that must not be interrupted is active.
|
|
57
|
+
* Watcher hard-stops (refuses to apply) until this returns false.
|
|
58
|
+
*/
|
|
59
|
+
export function gitMidOperation(projectRoot: string): boolean {
|
|
60
|
+
const sentinels = ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'rebase-apply', 'rebase-merge'];
|
|
61
|
+
for (const s of sentinels) {
|
|
62
|
+
if (existsSync(resolve(projectRoot, '.git', s))) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|