@massu/core 1.2.1 → 1.4.0-soak.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 +40 -0
- package/commands/README.md +137 -0
- 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-deploy.python.md +200 -0
- package/commands/massu-scaffold-page.md +172 -59
- package/commands/massu-scaffold-page.swift.md +121 -0
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/commands/massu-scaffold-router.python.md +143 -0
- package/dist/cli.js +10170 -4138
- package/dist/hooks/auto-learning-pipeline.js +44 -6
- package/dist/hooks/classify-failure.js +44 -6
- package/dist/hooks/cost-tracker.js +44 -6
- package/dist/hooks/fix-detector.js +44 -6
- package/dist/hooks/incident-pipeline.js +44 -6
- package/dist/hooks/post-edit-context.js +44 -6
- package/dist/hooks/post-tool-use.js +44 -6
- package/dist/hooks/pre-compact.js +44 -6
- package/dist/hooks/pre-delete-check.js +44 -6
- package/dist/hooks/quality-event.js +44 -6
- package/dist/hooks/rule-enforcement-pipeline.js +44 -6
- package/dist/hooks/session-end.js +44 -6
- package/dist/hooks/session-start.js +4789 -410
- package/dist/hooks/user-prompt.js +44 -6
- package/package.json +10 -4
- package/src/cli.ts +28 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +482 -42
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/show-template.ts +65 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +69 -3
- 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 +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- 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 +89 -0
- package/src/lsp/client.ts +590 -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,279 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Watch glob derivation.
|
|
6
|
+
*
|
|
7
|
+
* Watch surface = manifest files (always) + source directories
|
|
8
|
+
* (from massu.config.yaml's paths.* and framework.languages.*.source_dirs,
|
|
9
|
+
* or fallback safe-default globs when both absent), bounded by exclusion
|
|
10
|
+
* globs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fastGlob from 'fast-glob';
|
|
14
|
+
import type { Config } from '../config.ts';
|
|
15
|
+
|
|
16
|
+
export const ALWAYS_WATCH_FILES = [
|
|
17
|
+
'package.json',
|
|
18
|
+
'pyproject.toml',
|
|
19
|
+
'Cargo.toml',
|
|
20
|
+
'go.mod',
|
|
21
|
+
'Gemfile',
|
|
22
|
+
'*.csproj',
|
|
23
|
+
'mix.exs',
|
|
24
|
+
'requirements*.txt',
|
|
25
|
+
'setup.py',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const FALLBACK_SOURCE_GLOBS = [
|
|
29
|
+
'src/**',
|
|
30
|
+
'app/**',
|
|
31
|
+
'apps/**',
|
|
32
|
+
'packages/**',
|
|
33
|
+
'lib/**',
|
|
34
|
+
'cmd/**',
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_EXCLUSIONS = [
|
|
38
|
+
'**/node_modules/**',
|
|
39
|
+
'**/.venv/**',
|
|
40
|
+
'**/venv/**',
|
|
41
|
+
'**/target/**',
|
|
42
|
+
'**/build/**',
|
|
43
|
+
'**/dist/**',
|
|
44
|
+
'**/.git/**',
|
|
45
|
+
'**/.massu/**',
|
|
46
|
+
'**/.claude/**',
|
|
47
|
+
'**/__pycache__/**',
|
|
48
|
+
'**/.pytest_cache/**',
|
|
49
|
+
'**/.mypy_cache/**',
|
|
50
|
+
// Plan 3a hotfix 2026-05-02: high-churn directories that are never
|
|
51
|
+
// legitimate stack-detection inputs and produced sustained 30-100% CPU
|
|
52
|
+
// when watched on Hedge (62K files / 42 GB tree).
|
|
53
|
+
'**/.next/**',
|
|
54
|
+
'**/coverage/**',
|
|
55
|
+
'**/logs/**',
|
|
56
|
+
'**/*.log',
|
|
57
|
+
// Runtime data dirs. Convention across Python/JS/Rust ecosystems is
|
|
58
|
+
// that `data/` holds runtime artifacts (caches, snapshots, model
|
|
59
|
+
// checkpoints, downloaded fixtures) that change frequently but are
|
|
60
|
+
// never stack-detection inputs. Hedge had 135K files in
|
|
61
|
+
// apps/ai-service/data alone, dwarfing legitimate source. If a
|
|
62
|
+
// project genuinely uses `data/` for source content, opt into
|
|
63
|
+
// `watch.scope: 'full'` and `watch.paths_full_root_opt_in: true`.
|
|
64
|
+
'**/data/**',
|
|
65
|
+
// Iter-7 fix: editor temp files inside watched dirs fire spurious chokidar
|
|
66
|
+
// events and inflate the storm-detection counter without representing real
|
|
67
|
+
// stack changes. Cover the most common cases:
|
|
68
|
+
// *.swp / *.swo / 4913 -> vim atomic-write probe + swap files
|
|
69
|
+
// .#* -> emacs lockfiles
|
|
70
|
+
// *~ -> gedit / many editors backup
|
|
71
|
+
// .DS_Store -> macOS Finder metadata
|
|
72
|
+
'**/*.swp',
|
|
73
|
+
'**/*.swo',
|
|
74
|
+
'**/4913',
|
|
75
|
+
'**/.#*',
|
|
76
|
+
'**/*~',
|
|
77
|
+
'**/.DS_Store',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Plan 3a hotfix 2026-05-02: explicit root-watch sentinels. Any of these
|
|
82
|
+
* appearing in `framework.languages.*.source_dirs` is treated as the user
|
|
83
|
+
* asking to watch the entire repo root, which requires
|
|
84
|
+
* `watch.paths_full_root_opt_in: true` to override the file-count cap.
|
|
85
|
+
*
|
|
86
|
+
* Why these specifically: the toGlob() helper turns `'.'` into `'./**'`
|
|
87
|
+
* and leaves `'**'` / `'./**'` / `'./'` as-is. Each effectively makes
|
|
88
|
+
* chokidar walk the toplevel — silently, without warning — defeating
|
|
89
|
+
* the scope='paths' bound. We treat them as semantically equivalent to
|
|
90
|
+
* `scope: 'full'`.
|
|
91
|
+
*/
|
|
92
|
+
export const ROOT_WATCH_SENTINELS = ['.', './', '**', './**', '*'] as const;
|
|
93
|
+
|
|
94
|
+
export function isRootWatchSentinel(dir: string): boolean {
|
|
95
|
+
return (ROOT_WATCH_SENTINELS as readonly string[]).includes(dir);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class WatchSurfaceTooLargeError extends Error {
|
|
99
|
+
constructor(public readonly fileCount: number, public readonly cap: number) {
|
|
100
|
+
super(
|
|
101
|
+
`massu watch refuses to start: would monitor ${fileCount} files ` +
|
|
102
|
+
`(cap is ${cap}). Narrow framework.languages.*.source_dirs in ` +
|
|
103
|
+
`massu.config.yaml, or set watch.paths_full_root_opt_in: true (and ` +
|
|
104
|
+
`watch.max_watched_files: ${fileCount + 1000}) if root-level watching ` +
|
|
105
|
+
`is genuinely required. Note: monitoring more than 10K files routinely ` +
|
|
106
|
+
`produces 30-100% steady CPU under normal repo activity.`
|
|
107
|
+
);
|
|
108
|
+
this.name = 'WatchSurfaceTooLargeError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface DerivedWatchGlobs {
|
|
113
|
+
/** Globs/files to watch. */
|
|
114
|
+
watch: string[];
|
|
115
|
+
/** Globs to exclude. */
|
|
116
|
+
ignore: string[];
|
|
117
|
+
/** True when fallback globs were used (because config didn't declare any source paths). */
|
|
118
|
+
usedFallback: boolean;
|
|
119
|
+
/** Effective scope after considering root sentinels in source_dirs. */
|
|
120
|
+
effectiveScope: 'paths' | 'full';
|
|
121
|
+
/** True when a root sentinel ('.', '**', etc.) was detected in source_dirs and promoted to full scope. */
|
|
122
|
+
rootWatchDetected: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the watch + ignore glob set for chokidar from a loaded Config.
|
|
127
|
+
* Returns project-relative globs; the daemon resolves them against its root.
|
|
128
|
+
*
|
|
129
|
+
* `watch.scope` (Plan 3a §167 + §251 risk #1):
|
|
130
|
+
* - `'paths'` (default) — watch only declared `paths.*` + `framework.languages.*.source_dirs`
|
|
131
|
+
* (or fallback safe-default globs when none declared). Bounded watch surface.
|
|
132
|
+
* - `'full'` — watch the entire project root (`'**'`) bounded by exclusion globs.
|
|
133
|
+
* Opt-in for users on small repos who want every file under the toplevel
|
|
134
|
+
* to count. NOT recommended for huge (>10K-file) repos.
|
|
135
|
+
*/
|
|
136
|
+
export function deriveWatchGlobs(config: Config): DerivedWatchGlobs {
|
|
137
|
+
const sourceDirs = new Set<string>();
|
|
138
|
+
const explicitScope = config.watch?.scope ?? 'paths';
|
|
139
|
+
let rootWatchDetected = false;
|
|
140
|
+
|
|
141
|
+
if (explicitScope === 'full') {
|
|
142
|
+
// Full-repo opt-in. Just watch '**' and rely on DEFAULT_EXCLUSIONS for
|
|
143
|
+
// node_modules / .git / .massu / .claude / build dirs.
|
|
144
|
+
sourceDirs.add('**');
|
|
145
|
+
return {
|
|
146
|
+
watch: [...ALWAYS_WATCH_FILES, ...sourceDirs],
|
|
147
|
+
ignore: [...DEFAULT_EXCLUSIONS],
|
|
148
|
+
usedFallback: false,
|
|
149
|
+
effectiveScope: 'full',
|
|
150
|
+
rootWatchDetected: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (config.paths.source && typeof config.paths.source === 'string') {
|
|
155
|
+
if (isRootWatchSentinel(config.paths.source)) {
|
|
156
|
+
rootWatchDetected = true;
|
|
157
|
+
} else {
|
|
158
|
+
sourceDirs.add(toGlob(config.paths.source));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const langs = config.framework.languages;
|
|
163
|
+
if (langs && typeof langs === 'object') {
|
|
164
|
+
for (const langEntry of Object.values(langs)) {
|
|
165
|
+
// Defensive: some entries may not include source_dirs. Use property
|
|
166
|
+
// narrowing instead of an `as unknown as Shape` cast.
|
|
167
|
+
if (
|
|
168
|
+
langEntry &&
|
|
169
|
+
typeof langEntry === 'object' &&
|
|
170
|
+
'source_dirs' in langEntry
|
|
171
|
+
) {
|
|
172
|
+
const dirs = (langEntry as { source_dirs?: unknown }).source_dirs;
|
|
173
|
+
if (Array.isArray(dirs)) {
|
|
174
|
+
for (const d of dirs) {
|
|
175
|
+
if (typeof d !== 'string' || !d) continue;
|
|
176
|
+
if (isRootWatchSentinel(d)) {
|
|
177
|
+
rootWatchDetected = true;
|
|
178
|
+
} else {
|
|
179
|
+
sourceDirs.add(toGlob(d));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const usedFallback = sourceDirs.size === 0 && !rootWatchDetected;
|
|
188
|
+
if (usedFallback) {
|
|
189
|
+
for (const g of FALLBACK_SOURCE_GLOBS) sourceDirs.add(g);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Plan 3a hotfix: a root sentinel in source_dirs is semantically the same
|
|
193
|
+
// as scope='full'. Promote it. The daemon enforces the file-count cap on
|
|
194
|
+
// top of this; an unintentional `.` in source_dirs gets caught there.
|
|
195
|
+
let effectiveScope: 'paths' | 'full' = explicitScope;
|
|
196
|
+
if (rootWatchDetected) {
|
|
197
|
+
sourceDirs.add('**');
|
|
198
|
+
effectiveScope = 'full';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
watch: [...ALWAYS_WATCH_FILES, ...sourceDirs],
|
|
203
|
+
ignore: [...DEFAULT_EXCLUSIONS],
|
|
204
|
+
usedFallback,
|
|
205
|
+
effectiveScope,
|
|
206
|
+
rootWatchDetected,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toGlob(dir: string): string {
|
|
211
|
+
if (dir.endsWith('/**') || dir.includes('*')) return dir;
|
|
212
|
+
return dir.replace(/\/+$/, '') + '/**';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Plan 3a hotfix 2026-05-02: count files matching the derived watch globs
|
|
217
|
+
* (after exclusions) without walking past the cap. Used as a startup
|
|
218
|
+
* preflight in the daemon — refuses to start if count > cap and the user
|
|
219
|
+
* hasn't set `watch.paths_full_root_opt_in: true`.
|
|
220
|
+
*
|
|
221
|
+
* Why upfront vs after chokidar: chokidar.getWatched() requires waiting
|
|
222
|
+
* for the `ready` event, which on a 62K-file tree takes 30+ seconds AND
|
|
223
|
+
* does the full walk we're trying to avoid. fast-glob with `onlyFiles`
|
|
224
|
+
* + early-exit via the iterator pattern walks once, bails when we've
|
|
225
|
+
* counted enough to know we're over the cap.
|
|
226
|
+
*
|
|
227
|
+
* Returns Infinity if more than `cap + 1` files exist (signals "exceeds")
|
|
228
|
+
* to avoid walking the full tree just to produce an exact count.
|
|
229
|
+
*/
|
|
230
|
+
export async function countWatchSurface(
|
|
231
|
+
watch: readonly string[],
|
|
232
|
+
ignore: readonly string[],
|
|
233
|
+
cwd: string,
|
|
234
|
+
cap: number
|
|
235
|
+
): Promise<number> {
|
|
236
|
+
// fast-glob's stream API yields one path per event, allowing early-exit.
|
|
237
|
+
const stream = fastGlob.stream(watch as string[], {
|
|
238
|
+
cwd,
|
|
239
|
+
ignore: ignore as string[],
|
|
240
|
+
onlyFiles: true,
|
|
241
|
+
dot: false,
|
|
242
|
+
followSymbolicLinks: false,
|
|
243
|
+
suppressErrors: true,
|
|
244
|
+
});
|
|
245
|
+
let count = 0;
|
|
246
|
+
for await (const _ of stream) {
|
|
247
|
+
count += 1;
|
|
248
|
+
if (count > cap) {
|
|
249
|
+
// We've proven the surface exceeds the cap. Cancel the iterator and
|
|
250
|
+
// return a sentinel value. The caller only needs the exceeds-or-not
|
|
251
|
+
// signal (plus the cap) to produce a useful error.
|
|
252
|
+
// @ts-expect-error fast-glob stream is a NodeJS.ReadableStream
|
|
253
|
+
stream.destroy?.();
|
|
254
|
+
return Infinity;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return count;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Plan 3a hotfix 2026-05-02: enforce the watch.max_watched_files cap.
|
|
262
|
+
* Throws WatchSurfaceTooLargeError if the count exceeds the cap and the
|
|
263
|
+
* user has not opted in via `watch.paths_full_root_opt_in: true`.
|
|
264
|
+
*
|
|
265
|
+
* Returns the actual count (or Infinity if early-exit tripped) so the
|
|
266
|
+
* daemon can log the surface size at startup for observability.
|
|
267
|
+
*/
|
|
268
|
+
export async function enforceWatchSurfaceCap(
|
|
269
|
+
globs: DerivedWatchGlobs,
|
|
270
|
+
cwd: string,
|
|
271
|
+
cap: number,
|
|
272
|
+
optedIn: boolean
|
|
273
|
+
): Promise<number> {
|
|
274
|
+
const count = await countWatchSurface(globs.watch, globs.ignore, cwd, cap);
|
|
275
|
+
if (count > cap && !optedIn) {
|
|
276
|
+
throw new WatchSurfaceTooLargeError(count, cap);
|
|
277
|
+
}
|
|
278
|
+
return count;
|
|
279
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Watcher state persistence — `.massu/watch-state.json`.
|
|
6
|
+
*
|
|
7
|
+
* Crash-recovery contract: every mutation goes through writeStateAtomic()
|
|
8
|
+
* (write `.tmp` → fsyncSync → renameSync). At any kill -9 point either the
|
|
9
|
+
* old state or the new state survives intact (POSIX rename atomicity).
|
|
10
|
+
*
|
|
11
|
+
* Schema versioning: top-level `schema_version` field. Migrators in
|
|
12
|
+
* STATE_MIGRATORS bump from `from` → `from + 1` until MAX_SUPPORTED.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync } from 'fs';
|
|
16
|
+
import { dirname, resolve } from 'path';
|
|
17
|
+
|
|
18
|
+
export const MIN_SUPPORTED_SCHEMA_VERSION = 1;
|
|
19
|
+
export const MAX_SUPPORTED_SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export interface WatchState {
|
|
22
|
+
schema_version: number;
|
|
23
|
+
lastFingerprint: string | null;
|
|
24
|
+
lastRefreshAt: string | null;
|
|
25
|
+
lastError: string | null;
|
|
26
|
+
daemonPid: number | null;
|
|
27
|
+
startedAt: string | null;
|
|
28
|
+
tickedAt: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_STATE: WatchState = {
|
|
32
|
+
schema_version: MAX_SUPPORTED_SCHEMA_VERSION,
|
|
33
|
+
lastFingerprint: null,
|
|
34
|
+
lastRefreshAt: null,
|
|
35
|
+
lastError: null,
|
|
36
|
+
daemonPid: null,
|
|
37
|
+
startedAt: null,
|
|
38
|
+
tickedAt: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bumps state from a known prior version to `from + 1`. Empty at ship.
|
|
43
|
+
* When a future schema bump lands, register a migrator here for every
|
|
44
|
+
* `from` in [MIN_SUPPORTED_SCHEMA_VERSION..MAX_SUPPORTED_SCHEMA_VERSION-1].
|
|
45
|
+
*/
|
|
46
|
+
export const STATE_MIGRATORS: Record<number, (old: Record<string, unknown>) => Record<string, unknown>> = {};
|
|
47
|
+
|
|
48
|
+
export function watchStatePath(projectRoot: string): string {
|
|
49
|
+
return resolve(projectRoot, '.massu', 'watch-state.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function backupStatePath(projectRoot: string): string {
|
|
53
|
+
return resolve(projectRoot, '.massu', 'watch-state.v0.bak.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class WatchStateNewerError extends Error {
|
|
57
|
+
constructor(public stateVersion: number, public daemonMax: number) {
|
|
58
|
+
super(`watch-state from newer massu version (v=${stateVersion}, daemon supports v=${daemonMax}); refusing to overwrite — upgrade massu or delete .massu/watch-state.json`);
|
|
59
|
+
this.name = 'WatchStateNewerError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read state from disk. Behavior on schema mismatch:
|
|
65
|
+
* - missing schema_version: archive to backup path, return DEFAULT_STATE
|
|
66
|
+
* - older than supported: run STATE_MIGRATORS in sequence
|
|
67
|
+
* - newer than supported: throw WatchStateNewerError
|
|
68
|
+
*/
|
|
69
|
+
export function readState(projectRoot: string): WatchState {
|
|
70
|
+
const path = watchStatePath(projectRoot);
|
|
71
|
+
if (!existsSync(path)) return { ...DEFAULT_STATE };
|
|
72
|
+
|
|
73
|
+
// Read once; reuse the content for archive-on-corrupt to avoid a redundant
|
|
74
|
+
// disk read in the failure path (iter-9 simplify finding E1).
|
|
75
|
+
const content = readFileSync(path, 'utf-8');
|
|
76
|
+
|
|
77
|
+
let raw: unknown;
|
|
78
|
+
try {
|
|
79
|
+
raw = JSON.parse(content);
|
|
80
|
+
} catch {
|
|
81
|
+
archiveCorrupt(projectRoot, content);
|
|
82
|
+
return { ...DEFAULT_STATE };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
86
|
+
return { ...DEFAULT_STATE };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const obj = raw as Record<string, unknown>;
|
|
90
|
+
const ver = obj.schema_version;
|
|
91
|
+
|
|
92
|
+
if (typeof ver !== 'number') {
|
|
93
|
+
archiveCorrupt(projectRoot, content);
|
|
94
|
+
return { ...DEFAULT_STATE };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (ver > MAX_SUPPORTED_SCHEMA_VERSION) {
|
|
98
|
+
throw new WatchStateNewerError(ver, MAX_SUPPORTED_SCHEMA_VERSION);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let migrated: Record<string, unknown> = obj;
|
|
102
|
+
for (let v = ver; v < MAX_SUPPORTED_SCHEMA_VERSION; v++) {
|
|
103
|
+
const migrator = STATE_MIGRATORS[v];
|
|
104
|
+
if (!migrator) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`watch-state.json: missing migrator for schema_version ${v} -> ${v + 1}. ` +
|
|
107
|
+
`Daemon supports up to v=${MAX_SUPPORTED_SCHEMA_VERSION}; this is a massu bug. ` +
|
|
108
|
+
`Workaround: delete .massu/watch-state.json (the daemon will rebuild it on next start).`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
migrated = migrator(migrated);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
schema_version: MAX_SUPPORTED_SCHEMA_VERSION,
|
|
116
|
+
lastFingerprint: typeof migrated.lastFingerprint === 'string' ? migrated.lastFingerprint : null,
|
|
117
|
+
lastRefreshAt: typeof migrated.lastRefreshAt === 'string' ? migrated.lastRefreshAt : null,
|
|
118
|
+
lastError: typeof migrated.lastError === 'string' ? migrated.lastError : null,
|
|
119
|
+
daemonPid: typeof migrated.daemonPid === 'number' ? migrated.daemonPid : null,
|
|
120
|
+
startedAt: typeof migrated.startedAt === 'string' ? migrated.startedAt : null,
|
|
121
|
+
tickedAt: typeof migrated.tickedAt === 'string' ? migrated.tickedAt : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function archiveCorrupt(projectRoot: string, content: string): void {
|
|
126
|
+
const bak = backupStatePath(projectRoot);
|
|
127
|
+
mkdirSync(dirname(bak), { recursive: true });
|
|
128
|
+
writeFileSync(bak, content, 'utf-8');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Per-process counter for unique temp filenames. Combined with PID this
|
|
132
|
+
// makes the temp path unique across concurrent writers in the same project.
|
|
133
|
+
let writeStateAtomicCounter = 0;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Atomic write: tmp + fsync + rename. Survives kill -9 at any point.
|
|
137
|
+
*
|
|
138
|
+
* The temp filename is `<path>.<pid>.<counter>.tmp` so that two concurrent
|
|
139
|
+
* processes (e.g. foreground daemon + a `massu watch --apply-now` racing
|
|
140
|
+
* against it) never share the same temp file. POSIX rename is atomic per
|
|
141
|
+
* (target) so the final state file always reflects exactly one of the
|
|
142
|
+
* concurrent writers — never a torn write.
|
|
143
|
+
*/
|
|
144
|
+
export function writeStateAtomic(projectRoot: string, state: WatchState): void {
|
|
145
|
+
const path = watchStatePath(projectRoot);
|
|
146
|
+
writeStateAtomicCounter = (writeStateAtomicCounter + 1) >>> 0;
|
|
147
|
+
const tmp = `${path}.${process.pid}.${writeStateAtomicCounter}.tmp`;
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
149
|
+
|
|
150
|
+
// Iter-8 fix: clean up tmp on error. Without this, a writeSync/fsyncSync
|
|
151
|
+
// failure (e.g., ENOSPC, EIO mid-write) leaves a `.<pid>.<counter>.tmp`
|
|
152
|
+
// straggler in `.massu/` for every failed attempt. Mirrors the cleanup-on-
|
|
153
|
+
// error pattern used by `atomicWriteFile` in install-commands.ts.
|
|
154
|
+
let renamed = false;
|
|
155
|
+
try {
|
|
156
|
+
const fd = openSync(tmp, 'w');
|
|
157
|
+
try {
|
|
158
|
+
const buf = Buffer.from(JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
159
|
+
writeSync(fd, buf, 0, buf.length, 0);
|
|
160
|
+
fsyncSync(fd);
|
|
161
|
+
} finally {
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
renameSync(tmp, path);
|
|
165
|
+
renamed = true;
|
|
166
|
+
} finally {
|
|
167
|
+
if (!renamed && existsSync(tmp)) {
|
|
168
|
+
try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function updateState(projectRoot: string, patch: Partial<WatchState>): WatchState {
|
|
174
|
+
const current = readState(projectRoot);
|
|
175
|
+
const next: WatchState = { ...current, ...patch, schema_version: MAX_SUPPORTED_SCHEMA_VERSION };
|
|
176
|
+
writeStateAtomic(projectRoot, next);
|
|
177
|
+
return next;
|
|
178
|
+
}
|