Add daemonized web gateway behavior for tithe web#9
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the tithe web CLI workflow to support running the API + PWA stack under a detached “daemon supervisor” process, with lifecycle controls for status/stop and enriched startup metadata (local + best-effort Tailnet URLs).
Changes:
- Add
--daemon,--status, and--stopcontrol flags totithe web, plus an internalweb-supervisorcommand used for background supervision. - Implement daemon state/pid/log file management and supervisor auto-restart backoff logic for API/PWA child processes.
- Update docs and add CLI validation tests for incompatible flag combinations.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
apps/cli/src/web.ts |
Implements daemon/supervisor lifecycle, state persistence, access metadata (Tailscale), and option validation. |
apps/cli/src/index.ts |
Exposes new web flags and adds the internal web-supervisor command wiring. |
tests/cli/web.spec.ts |
Adds coverage for new validation paths around daemon control flags. |
README.md |
Documents new default mode and daemon lifecycle behavior. |
AGENTS.md |
Updates “Web runtime” command docs and CLI behavior notes for daemon support. |
.agents/notes.md |
Adds internal notes about the shim behavior and daemon metadata path resolution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
apps/cli/src/web.ts
Outdated
| fs.writeFileSync(tempFile, JSON.stringify(value, null, 2)); | ||
| fs.renameSync(tempFile, filePath); |
There was a problem hiding this comment.
writeJsonFile uses fs.renameSync(tempFile, filePath) to update the daemon state atomically, but rename cannot overwrite an existing destination file on Windows. After the first write, subsequent persistDaemonState calls can throw and crash the supervisor. Consider using an overwrite-capable approach on win32 (e.g., delete/replace destination first, or fallback to writeFileSync(filePath, ...) when renameSync fails with EEXIST/EPERM).
| fs.writeFileSync(tempFile, JSON.stringify(value, null, 2)); | |
| fs.renameSync(tempFile, filePath); | |
| const data = JSON.stringify(value, null, 2); | |
| fs.writeFileSync(tempFile, data); | |
| try { | |
| if (process.platform === 'win32' && fs.existsSync(filePath)) { | |
| fs.unlinkSync(filePath); | |
| } | |
| fs.renameSync(tempFile, filePath); | |
| } catch (error: any) { | |
| try { | |
| const code = (error as NodeJS.ErrnoException).code; | |
| if ( | |
| process.platform === 'win32' && | |
| (code === 'EEXIST' || code === 'EPERM') | |
| ) { | |
| // Fallback: overwrite the destination directly on Windows | |
| fs.writeFileSync(filePath, data); | |
| fs.unlinkSync(tempFile); | |
| return; | |
| } | |
| } finally { | |
| if (fs.existsSync(tempFile)) { | |
| fs.unlinkSync(tempFile); | |
| } | |
| } | |
| throw error; | |
| } |
There was a problem hiding this comment.
Fixed in 9197614. writeJsonFile now handles Windows rename edge cases by unlinking destination first on win32 and falling back to direct overwrite for EEXIST/EPERM, with temp-file cleanup in finally.
| const status = spawnSync('tailscale', ['status', '--json'], { | ||
| encoding: 'utf8', | ||
| }); |
There was a problem hiding this comment.
spawnSync('tailscale', ...) has no timeout, so a hung tailscale status --json call can block tithe web startup/status output indefinitely (including in foreground mode). Consider setting a reasonable timeout (and checking status.signal) to ensure the CLI remains responsive when Tailscale is slow/unresponsive.
There was a problem hiding this comment.
Fixed in 9197614. tailscale status calls now use a 2000ms timeout and we surface a timeout warning when the command is terminated by signal, so startup and status do not block indefinitely.
| const cleanup = () => { | ||
| process.off('SIGINT', handleSignal); | ||
| process.off('SIGTERM', handleSignal); | ||
| for (const timer of restartTimers.values()) { | ||
| clearTimeout(timer); | ||
| } | ||
| restartTimers.clear(); | ||
| }; | ||
|
|
||
| const finishResolve = () => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
| settled = true; | ||
| cleanup(); | ||
| resolve(); | ||
| }; | ||
|
|
||
| const finishReject = (error: AppError) => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
| settled = true; | ||
| cleanup(); | ||
| reject(error); |
There was a problem hiding this comment.
cleanup()/finishReject() in the supervisor loop clear timers and handlers but do not stop any already-running child services. If the supervisor exits due to a fatal error path, API/PWA processes can be orphaned while the pid file is removed, making --stop ineffective and leaving ports occupied. Consider stopping/killing children on reject (and/or adding process.on('exit'|'uncaughtException'|'unhandledRejection') to ensure children are terminated).
There was a problem hiding this comment.
Fixed in 9197614. finishReject now marks shutdown and sends SIGTERM to running API/PWA children before rejecting, so fatal supervisor paths no longer leave orphan processes behind.
Summary
Validation
Notes