Skip to content

Commit 465b4fa

Browse files
Merge pull request #22 from radiantlab/watch-error-scanning
Added unified watch scripts for svc/web error checks
2 parents 3421613 + 464ffa9 commit 465b4fa

5 files changed

Lines changed: 293 additions & 1 deletion

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ An overview of the file structure:
3232
- `web/` for the researcher UI (Contains a README specific to the web interface)
3333
- `svc/` for the control service and queue (Contains a README specific to the backend service)
3434

35+
## Watch for errors
36+
Run the watcher from `root`, `svc`, or `web`:
37+
38+
```bash
39+
npm run watch
40+
```
41+
42+
Defaults by folder:
43+
- root: backend + frontend (`both`)
44+
- svc: backend only
45+
- web: frontend only
46+
47+
Override the target in any folder:
48+
49+
```bash
50+
npm run watch -- backend
51+
npm run watch -- frontend
52+
npm run watch -- both
53+
```
54+
3555
## License
3656

3757
GPL 3.0 License. See LICENSE.

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "glazing-control-workspace",
3+
"private": true,
4+
"scripts": {
5+
"watch": "node scripts/watch.mjs",
6+
"watch:backend": "node scripts/watch.mjs backend",
7+
"watch:frontend": "node scripts/watch.mjs frontend",
8+
"watch:both": "node scripts/watch.mjs both"
9+
}
10+
}

scripts/watch.mjs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { spawn } from "node:child_process";
2+
import { existsSync, readdirSync, statSync } from "node:fs";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const repoRoot = path.resolve(path.dirname(__filename), "..");
8+
const svcDir = path.join(repoRoot, "svc");
9+
const webDir = path.join(repoRoot, "web");
10+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
11+
const uvCommand = process.platform === "win32" ? "uv.exe" : "uv";
12+
const allowedTargets = new Set(["backend", "frontend", "both"]);
13+
14+
const selected = process.argv[2]?.toLowerCase();
15+
if (selected && !allowedTargets.has(selected)) {
16+
console.error("Usage: npm run watch -- [backend|frontend|both]");
17+
process.exit(1);
18+
}
19+
20+
const target = selected ?? inferDefaultTarget(process.cwd());
21+
console.log(`[watch] mode=${target}`);
22+
23+
let shuttingDown = false;
24+
let frontendProcess = null;
25+
let backendProcess = null;
26+
let backendPollingTimer = null;
27+
let backendRunInProgress = false;
28+
let backendRunQueued = false;
29+
let backendSnapshot = new Map();
30+
31+
start();
32+
33+
function start() {
34+
if (target === "frontend" || target === "both") {
35+
startFrontendWatcher();
36+
}
37+
38+
if (target === "backend" || target === "both") {
39+
startBackendWatcher();
40+
}
41+
}
42+
43+
function inferDefaultTarget(cwd) {
44+
const resolvedCwd = path.resolve(cwd);
45+
if (isPathInside(svcDir, resolvedCwd)) {
46+
return "backend";
47+
}
48+
49+
if (isPathInside(webDir, resolvedCwd)) {
50+
return "frontend";
51+
}
52+
53+
return "both";
54+
}
55+
56+
function isPathInside(parentDir, candidateDir) {
57+
const parent = path.resolve(parentDir);
58+
const candidate = path.resolve(candidateDir);
59+
return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
60+
}
61+
62+
function startFrontendWatcher() {
63+
if (!existsSync(path.join(webDir, "package.json"))) {
64+
console.error(`[watch] missing ${path.join(webDir, "package.json")}`);
65+
shutdown(1);
66+
return;
67+
}
68+
69+
console.log("[watch:frontend] starting TypeScript watch");
70+
frontendProcess = spawn(npmCommand, ["run", "typecheck:watch"], {
71+
cwd: webDir,
72+
stdio: "inherit",
73+
env: process.env,
74+
});
75+
76+
frontendProcess.on("error", (error) => {
77+
console.error(`[watch:frontend] failed to start: ${error.message}`);
78+
shutdown(1);
79+
});
80+
81+
frontendProcess.on("exit", (code) => {
82+
frontendProcess = null;
83+
if (shuttingDown) {
84+
return;
85+
}
86+
87+
console.error(`[watch:frontend] exited with code ${code ?? "unknown"}`);
88+
shutdown(code ?? 1);
89+
});
90+
}
91+
92+
function startBackendWatcher() {
93+
if (!existsSync(path.join(svcDir, "pyproject.toml"))) {
94+
console.error(`[watch] missing ${path.join(svcDir, "pyproject.toml")}`);
95+
shutdown(1);
96+
return;
97+
}
98+
99+
backendSnapshot = createBackendSnapshot();
100+
console.log("[watch:backend] watching svc/*.py changes and re-running pytest");
101+
runBackendTests("initial run");
102+
103+
backendPollingTimer = setInterval(() => {
104+
if (shuttingDown) {
105+
return;
106+
}
107+
108+
const nextSnapshot = createBackendSnapshot();
109+
if (snapshotsDiffer(backendSnapshot, nextSnapshot)) {
110+
backendSnapshot = nextSnapshot;
111+
scheduleBackendRun("source change");
112+
}
113+
}, 1200);
114+
}
115+
116+
function scheduleBackendRun(reason) {
117+
if (backendRunInProgress) {
118+
backendRunQueued = true;
119+
return;
120+
}
121+
122+
runBackendTests(reason);
123+
}
124+
125+
function runBackendTests(reason) {
126+
backendRunInProgress = true;
127+
console.log(`[watch:backend] running pytest (${reason})`);
128+
129+
backendProcess = spawn(uvCommand, ["run", "pytest", "-q"], {
130+
cwd: svcDir,
131+
stdio: "inherit",
132+
env: process.env,
133+
});
134+
135+
backendProcess.on("error", (error) => {
136+
console.error(`[watch:backend] failed to start: ${error.message}`);
137+
shutdown(1);
138+
});
139+
140+
backendProcess.on("exit", (code) => {
141+
backendProcess = null;
142+
backendRunInProgress = false;
143+
if (shuttingDown) {
144+
return;
145+
}
146+
147+
const stamp = new Date().toLocaleTimeString();
148+
if (code === 0) {
149+
console.log(`[watch:backend] ${stamp} no test errors`);
150+
} else {
151+
console.log(`[watch:backend] ${stamp} errors found (exit ${code ?? "unknown"})`);
152+
}
153+
154+
if (backendRunQueued) {
155+
backendRunQueued = false;
156+
runBackendTests("queued source change");
157+
}
158+
});
159+
}
160+
161+
function createBackendSnapshot() {
162+
const snapshot = new Map();
163+
const watchDirs = ["app", "tests"];
164+
const watchFiles = ["main.py", "pyproject.toml", "requirements.txt"];
165+
const watchExt = new Set([".py", ".toml"]);
166+
167+
for (const file of watchFiles) {
168+
const filePath = path.join(svcDir, file);
169+
if (existsSync(filePath)) {
170+
snapshot.set(filePath, statSync(filePath).mtimeMs);
171+
}
172+
}
173+
174+
for (const dir of watchDirs) {
175+
const dirPath = path.join(svcDir, dir);
176+
if (!existsSync(dirPath)) {
177+
continue;
178+
}
179+
180+
walkDirectory(dirPath, snapshot, watchExt);
181+
}
182+
183+
return snapshot;
184+
}
185+
186+
function walkDirectory(directory, snapshot, watchExt) {
187+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
188+
const entryPath = path.join(directory, entry.name);
189+
if (entry.isDirectory()) {
190+
if (entry.name === "__pycache__" || entry.name === ".venv") {
191+
continue;
192+
}
193+
194+
walkDirectory(entryPath, snapshot, watchExt);
195+
continue;
196+
}
197+
198+
const ext = path.extname(entry.name).toLowerCase();
199+
if (watchExt.has(ext)) {
200+
snapshot.set(entryPath, statSync(entryPath).mtimeMs);
201+
}
202+
}
203+
}
204+
205+
function snapshotsDiffer(previous, current) {
206+
if (previous.size !== current.size) {
207+
return true;
208+
}
209+
210+
for (const [file, mtime] of current.entries()) {
211+
if (!previous.has(file) || previous.get(file) !== mtime) {
212+
return true;
213+
}
214+
}
215+
216+
return false;
217+
}
218+
219+
function shutdown(exitCode = 0) {
220+
if (shuttingDown) {
221+
return;
222+
}
223+
224+
shuttingDown = true;
225+
if (backendPollingTimer) {
226+
clearInterval(backendPollingTimer);
227+
backendPollingTimer = null;
228+
}
229+
230+
for (const child of [backendProcess, frontendProcess]) {
231+
if (!child) {
232+
continue;
233+
}
234+
235+
try {
236+
child.kill("SIGTERM");
237+
} catch {
238+
// Ignore shutdown errors from already-finished children.
239+
}
240+
}
241+
242+
setTimeout(() => process.exit(exitCode), 80);
243+
}
244+
245+
process.on("SIGINT", () => shutdown(0));
246+
process.on("SIGTERM", () => shutdown(0));

svc/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "glazing-control-service-tools",
3+
"private": true,
4+
"scripts": {
5+
"watch": "node ../scripts/watch.mjs",
6+
"watch:backend": "node ../scripts/watch.mjs backend",
7+
"watch:frontend": "node ../scripts/watch.mjs frontend",
8+
"watch:both": "node ../scripts/watch.mjs both"
9+
}
10+
}

web/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
8+
"typecheck": "tsc --noEmit",
9+
"typecheck:watch": "tsc --noEmit --watch --preserveWatchOutput",
10+
"watch": "node ../scripts/watch.mjs",
11+
"watch:backend": "node ../scripts/watch.mjs backend",
12+
"watch:frontend": "node ../scripts/watch.mjs frontend",
13+
"watch:both": "node ../scripts/watch.mjs both",
814
"build": "vite build",
915
"preview": "vite preview"
1016
},
@@ -18,4 +24,4 @@
1824
"typescript": "5.3.3",
1925
"vite": "5.4.10"
2026
}
21-
}
27+
}

0 commit comments

Comments
 (0)