diff --git a/Dockerfile b/Dockerfile
index ef44c0a..46d1fc0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -46,9 +46,11 @@ COPY src ./src
COPY config.docker.json ./config.docker.json
COPY docker-entrypoint.sh ./docker-entrypoint.sh
-# Bake git hash into image for version tracking (passed via --build-arg)
+# Bake git hash and timestamp into image for version tracking (passed via --build-arg)
ARG BUILD_GIT_HASH=unknown
RUN echo "${BUILD_GIT_HASH}" > /app/.git-hash
+ARG BUILD_GIT_TIMESTAMP=0
+RUN echo "${BUILD_GIT_TIMESTAMP}" > /app/.git-timestamp
# Fix Windows CRLF line endings (build context may come from Windows filesystem)
RUN sed -i 's/\r$//' ./docker-entrypoint.sh && chmod +x ./docker-entrypoint.sh
diff --git a/public/index.html b/public/index.html
index f5bc81d..67c0477 100644
--- a/public/index.html
+++ b/public/index.html
@@ -243,6 +243,31 @@
Watcher Status
}
}
+ async function stopSpecificWatcher(watcherId, btn) {
+ btn.disabled = true;
+ btn.textContent = 'Stopping...';
+ try {
+ await fetch('/api/watcher/stop', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workspace: WorkspaceContext.active, watcherId })
+ });
+ // Poll until this watcher disappears (up to 20s)
+ for (let i = 0; i < 10; i++) {
+ await new Promise(r => setTimeout(r, 2000));
+ const sr = await WorkspaceContext.serviceFetch('/watcher-status');
+ const sd = await sr.json();
+ const still = (sd.watchers || []).find(w => w.watcherId === watcherId && w.status === 'active');
+ if (!still) { loadWatcher(); loadOverview(); return; }
+ }
+ btn.textContent = 'Timed out';
+ setTimeout(() => { btn.textContent = 'Stop'; btn.disabled = false; }, 3000);
+ } catch (err) {
+ btn.textContent = 'Error';
+ setTimeout(() => { btn.textContent = 'Stop'; btn.disabled = false; }, 3000);
+ }
+ }
+
async function loadWatcher() {
const watcherEl = document.getElementById('watcher');
try {
@@ -275,7 +300,26 @@ Watcher Status
Errors ${w.counters?.errorsCount || 0}
Last ingest ${lastIngest}
Next reconcile ${nextReconcile}
- ${data.watchers.filter(x => x.status === 'active').length > 1 ? `Warning ${data.watchers.filter(x => x.status === 'active').length} watchers connected
` : ''}
+
+ Restart Watcher
+
+
+ ${activeWatchers.length > 1 ? `
+ Warning ${activeWatchers.length} watchers connected
+
+
Active watchers — stop duplicates to avoid redundant indexing:
+ ${activeWatchers.map(aw => {
+ const id = aw.watcherId || 'unknown';
+ const started = aw.startedAt ? formatRelative(aw.startedAt) : '?';
+ const files = (aw.counters?.filesIngested || 0).toLocaleString();
+ return `
+ ${esc(id)}
+ started ${started} · ${files} files
+ Stop
+
`;
+ }).join('')}
+
+ ` : ''}
`;
} else {
watcherEl.innerHTML = `
@@ -420,17 +464,33 @@ Watcher Status
}
// Version mismatch detection
+ const serviceGitTimestamp = healthData?.gitTimestamp || 0;
+ const watcherGitTimestamp = w?.gitTimestamp || 0;
const mismatch = serviceGitHash && watcherGitHash
&& serviceGitHash !== 'unknown' && watcherGitHash !== 'unknown'
&& serviceGitHash !== watcherGitHash;
if (mismatch) {
+ let message, actionHtml;
+ if (serviceGitTimestamp && watcherGitTimestamp && serviceGitTimestamp !== watcherGitTimestamp) {
+ if (serviceGitTimestamp < watcherGitTimestamp) {
+ message = 'Service is outdated';
+ actionHtml = 'Rebuild & Restart Service ';
+ } else {
+ message = 'Watcher is outdated';
+ actionHtml = 'Restart Watcher ';
+ }
+ } else {
+ message = 'Version mismatch';
+ actionHtml = `
+ Restart Watcher
+ Rebuild Service `;
+ }
versionAlert.classList.add('visible');
document.getElementById('version-alert-hashes').textContent =
`Service (${serviceGitHash}) \u00b7 Watcher (${watcherGitHash})`;
- const actionsEl = document.getElementById('version-alert-actions');
- actionsEl.innerHTML = `
- Restart Watcher
- `;
+ document.querySelector('.version-alert-header .label').textContent = message;
+ document.getElementById('version-alert-actions').innerHTML =
+ actionHtml + ' ';
} else {
versionAlert.classList.remove('visible');
}
@@ -464,32 +524,54 @@ Watcher Status
}
async function stopAndRestartWatcher(btn) {
- const status = document.getElementById('version-alert-status');
+ // Find the nearest status element — works from version alert or watcher card
+ const status = document.getElementById('version-alert-status')
+ || document.getElementById('watcher-restart-status')
+ || btn.parentElement?.querySelector('span');
+ const setStatus = msg => { if (status) status.textContent = msg; };
btn.disabled = true;
btn.textContent = 'Stopping watcher...';
- status.textContent = '';
+ setStatus('');
try {
- await fetch('/api/watcher/stop', {
+ // Stop via setup-gui (uses direct PID kill — instant, no heartbeat delay)
+ const stopResp = await fetch('/api/watcher/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspace: WorkspaceContext.active })
});
- status.textContent = 'Waiting for watcher to stop...';
- let stopped = false;
- for (let i = 0; i < 20; i++) {
- await new Promise(r => setTimeout(r, 1000));
- const sr = await WorkspaceContext.serviceFetch('/watcher-status');
- const sd = await sr.json();
- if (!sd.hasActiveWatcher) { stopped = true; break; }
- }
- if (!stopped) {
- status.textContent = 'Watcher did not stop in time. Try closing it manually.';
- btn.textContent = 'Restart Watcher';
- btn.disabled = false;
- return;
+ const stopData = await stopResp.json();
+ const wasKilled = stopData.method === 'pid-kill';
+
+ // Brief wait for process to die (PID kill is ~instant, heartbeat needs longer)
+ const waitMs = wasKilled ? 1000 : 5000;
+ setStatus(wasKilled ? 'Process killed, starting new watcher...' : 'Waiting for watcher to stop...');
+ await new Promise(r => setTimeout(r, waitMs));
+
+ // If heartbeat-based, poll until the old watcher is gone
+ if (!wasKilled) {
+ let stopped = false;
+ for (let i = 0; i < 15; i++) {
+ try {
+ const sr = await WorkspaceContext.serviceFetch('/watcher-status');
+ const sd = await sr.json();
+ if (!sd.hasActiveWatcher) { stopped = true; break; }
+ } catch {
+ // Service unreachable — watcher is likely already gone
+ stopped = true; break;
+ }
+ await new Promise(r => setTimeout(r, 1000));
+ }
+ if (!stopped) {
+ setStatus('Watcher did not stop in time. Try closing it manually.');
+ btn.textContent = 'Restart Watcher';
+ btn.disabled = false;
+ return;
+ }
}
+
+ // Start a new watcher
btn.textContent = 'Starting watcher...';
- status.textContent = '';
+ setStatus('');
const startResp = await fetch('/api/watcher/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -497,23 +579,27 @@ Watcher Status
});
if (!startResp.ok) {
const err = await startResp.json();
- status.textContent = err.error || 'Failed to start watcher';
+ setStatus(err.error || 'Failed to start watcher');
btn.textContent = 'Restart Watcher';
btn.disabled = false;
return;
}
- status.textContent = 'Waiting for new watcher...';
+ setStatus('Waiting for new watcher to connect...');
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000));
- const sr = await WorkspaceContext.serviceFetch('/watcher-status');
- const sd = await sr.json();
- if (sd.hasActiveWatcher) { loadAllWithOverview(); return; }
+ try {
+ const sr = await WorkspaceContext.serviceFetch('/watcher-status');
+ const sd = await sr.json();
+ if (sd.hasActiveWatcher) { loadAllWithOverview(); return; }
+ } catch {
+ // Service might still be starting — keep polling
+ }
}
- status.textContent = 'Watcher started but still reconciling. Refresh shortly.';
+ setStatus('Watcher started but still connecting. Refresh shortly.');
btn.textContent = 'Restart Watcher';
btn.disabled = false;
} catch (err) {
- status.textContent = 'Error: ' + err.message;
+ setStatus('Error: ' + err.message);
btn.textContent = 'Restart Watcher';
btn.disabled = false;
}
@@ -608,6 +694,75 @@ Watcher Status
}, 2000);
}
+ async function overviewRebuildAndRestart(btn) {
+ const status = document.getElementById('version-alert-status');
+ btn.disabled = true;
+ btn.textContent = 'Rebuilding...';
+ if (status) status.textContent = '';
+ try {
+ // Step 1: Trigger Docker rebuild via SSE
+ const buildResp = await new Promise((resolve, reject) => {
+ const evtSource = new EventSource('/api/docker/build');
+ let lastLine = '';
+ evtSource.addEventListener('output', e => {
+ const data = JSON.parse(e.data);
+ lastLine = data.line;
+ if (status) status.textContent = lastLine;
+ });
+ evtSource.addEventListener('done', e => {
+ const data = JSON.parse(e.data);
+ evtSource.close();
+ resolve(data);
+ });
+ evtSource.addEventListener('error', e => {
+ evtSource.close();
+ reject(new Error('Build stream error'));
+ });
+ evtSource.onerror = () => { evtSource.close(); reject(new Error('Build connection lost')); };
+ });
+ if (buildResp.code !== 0) {
+ if (status) status.textContent = 'Build failed (exit code ' + buildResp.code + ')';
+ btn.textContent = 'Rebuild & Restart Service';
+ btn.disabled = false;
+ return;
+ }
+ // Step 2: Restart container
+ btn.textContent = 'Restarting...';
+ if (status) status.textContent = 'Restarting container...';
+ const startResp = await fetch('/api/docker/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workspace: WorkspaceContext.active })
+ });
+ if (!startResp.ok) {
+ const err = await startResp.json();
+ if (status) status.textContent = err.error || 'Container restart failed';
+ btn.textContent = 'Rebuild & Restart Service';
+ btn.disabled = false;
+ return;
+ }
+ // Step 3: Wait for service to come back
+ if (status) status.textContent = 'Waiting for service...';
+ for (let i = 0; i < 30; i++) {
+ await new Promise(r => setTimeout(r, 2000));
+ try {
+ const resp = await WorkspaceContext.serviceFetch('/health');
+ if (resp.ok) {
+ loadAllWithOverview();
+ return;
+ }
+ } catch {}
+ }
+ if (status) status.textContent = 'Service did not come back. Check Docker logs.';
+ btn.textContent = 'Rebuild & Restart Service';
+ btn.disabled = false;
+ } catch (err) {
+ if (status) status.textContent = 'Error: ' + err.message;
+ btn.textContent = 'Rebuild & Restart Service';
+ btn.disabled = false;
+ }
+ }
+
// --- Projects card (merged config + stats + freshness) ---
async function loadConfig() {
diff --git a/public/setup.html b/public/setup.html
index cb864ce..8ec0f72 100644
--- a/public/setup.html
+++ b/public/setup.html
@@ -92,6 +92,17 @@
.error-msg { color: #f44747; font-size: 13px; margin-top: 4px; }
.success-msg { color: #4ec9b0; font-size: 13px; margin-top: 4px; }
.info-msg { color: #808080; font-size: 12px; margin-top: 4px; }
+
+ .watcher-status { margin-top: 8px; padding: 8px 12px; background: #1e1e1e; border: 1px solid #3e3e3e; border-radius: 4px; font-size: 12px; }
+ .watcher-status .ws-label { color: #808080; margin-bottom: 4px; }
+ .watcher-status .ws-label .dot { vertical-align: middle; }
+ .watcher-progress-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-family: 'Cascadia Code', 'Consolas', monospace; }
+ .watcher-progress-row .proj-name { width: 140px; color: #d4d4d4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .watcher-progress-bar { flex: 1; height: 10px; background: #2d2d2d; border-radius: 3px; overflow: hidden; max-width: 200px; }
+ .watcher-progress-bar .fill { height: 100%; background: #4ec9b0; border-radius: 3px; transition: width 0.4s ease; }
+ .watcher-progress-pct { width: 50px; text-align: right; color: #9cdcfe; }
+ .watcher-progress-count { color: #808080; }
+ .watcher-progress-done { color: #4ec9b0; }
@@ -408,6 +419,9 @@ Install Hooks
Memory
Delete
+
Installs a Claude Code hook that routes MCP tool calls (find_type, grep, etc.) to this workspace's container. Creates a .claude/ directory with hook config and a proxy binary.
@@ -567,6 +581,16 @@
Delete workspace "${esc(name)}"? setTimeout(r, 1500));
+ } catch { /* watcher may not be running, that's fine */ }
const qs = result.deleteVolumes ? '?deleteVolumes=true' : '';
await fetch(`/api/workspaces/${name}${qs}`, { method: 'DELETE' });
loadWorkspaces();
@@ -1420,7 +1444,23 @@ Delete workspace "${esc(name)}"?Started! Open Dashboard `;
+ status.innerHTML = `Container started! Starting watcher...`;
+ // Auto-start the watcher after the container is up
+ try {
+ const watcherResp = await fetch('/api/watcher/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workspace: wizardData.workspaceName })
+ });
+ const watcherData = await watcherResp.json();
+ if (watcherResp.ok && watcherData.ok) {
+ status.innerHTML = `Container and watcher started! Open Dashboard `;
+ } else {
+ status.innerHTML = `Container started! Watcher: ${esc(watcherData.error || 'failed to start')} Open Dashboard `;
+ }
+ } catch {
+ status.innerHTML = `Container started! Could not auto-start watcher Open Dashboard `;
+ }
} else {
const errMsg = data.error || 'unknown';
if (errMsg.includes('address already in use')) {
@@ -1662,6 +1702,103 @@ Delete workspace "${esc(name)}"?= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
+ return String(n);
+}
+
+function renderWatcherProgress(name, data) {
+ const el = document.getElementById('ws-watcher-' + name);
+ if (!el) return;
+
+ if (!data || !data.hasActiveWatcher) {
+ el.innerHTML = ' No watcher connected
';
+ return;
+ }
+
+ const watcher = data.watchers[0];
+ const progress = watcher.scanProgress || {};
+ const projectNames = Object.keys(progress);
+
+ if (projectNames.length === 0) {
+ el.innerHTML = ' Watcher: connected
';
+ return;
+ }
+
+ let html = ' Watcher: connected
';
+
+ for (const proj of projectNames) {
+ const p = progress[proj];
+ const total = p.discovered || 0;
+ const done = p.processed || 0;
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
+ const lang = (p.language === 'angelscript' ? 'AS' : p.language === 'content' ? 'assets' : p.language || '').toUpperCase();
+ const isActive = p.phase === 'scanning' || p.phase === 'reconciling';
+ const isDone = p.phase === 'watching' || p.phase === 'done';
+
+ html += '';
+ html += `
${esc(proj)} `;
+
+ if (isActive) {
+ html += `
`;
+ html += `
${pct}% `;
+ html += `
(${formatCount(done)}/${formatCount(total)} ${esc(lang)}) `;
+ } else if (isDone) {
+ html += `
`;
+ html += `
done (${formatCount(total)} files) `;
+ } else {
+ html += `
${esc(p.phase || 'idle')} `;
+ }
+
+ html += '
';
+ }
+
+ el.innerHTML = html;
+}
+
+async function pollWatcherStatus() {
+ const entries = Object.entries(workspacesCache);
+ for (const [name, ws] of entries) {
+ if (!ws.running) continue;
+ try {
+ const resp = await fetch(`/api/service-proxy/watcher-status?workspace=${encodeURIComponent(name)}`);
+ if (resp.ok) {
+ const data = await resp.json();
+ renderWatcherProgress(name, data);
+ }
+ } catch {
+ // Silently ignore — container may not be ready yet
+ }
+ }
+}
+
+function startWatcherPolling() {
+ if (watcherPollTimer) return;
+ const hasRunning = Object.values(workspacesCache).some(ws => ws.running);
+ if (!hasRunning) return;
+ pollWatcherStatus();
+ watcherPollTimer = setInterval(pollWatcherStatus, 5000);
+}
+
+function stopWatcherPolling() {
+ if (watcherPollTimer) {
+ clearInterval(watcherPollTimer);
+ watcherPollTimer = null;
+ }
+}
+
+// Hook into loadWorkspaces to start/stop polling
+const _origLoadWorkspaces = loadWorkspaces;
+loadWorkspaces = async function() {
+ await _origLoadWorkspaces();
+ stopWatcherPolling();
+ startWatcherPolling();
+};
+
// ── Init ───────────────────────────────────────────────
checkPrereqs();
diff --git a/src/service/api.js b/src/service/api.js
index e3dae4d..1395b31 100644
--- a/src/service/api.js
+++ b/src/service/api.js
@@ -28,6 +28,14 @@ try {
if (hash && hash !== 'unknown') SERVICE_GIT_HASH = hash;
} catch {}
}
+let SERVICE_GIT_TIMESTAMP = 0;
+try {
+ SERVICE_GIT_TIMESTAMP = parseInt(execSync('git log -1 --format=%ct HEAD', { cwd: join(__dirname, '..', '..'), encoding: 'utf-8' }).trim(), 10) || 0;
+} catch {
+ try {
+ SERVICE_GIT_TIMESTAMP = parseInt(readFileSync(join(__dirname, '..', '..', '.git-timestamp'), 'utf-8').trim(), 10) || 0;
+ } catch {}
+}
// LRU+TTL cache for /grep results — agents often repeat the same search
class GrepCache {
@@ -353,6 +361,7 @@ export function createApi(database, indexer, queryPool = null, { zoektClient = n
status: 'ok',
version: SERVICE_VERSION,
gitHash: SERVICE_GIT_HASH,
+ gitTimestamp: SERVICE_GIT_TIMESTAMP,
timestamp: new Date().toISOString(),
uptimeSeconds: Math.round(process.uptime()),
memoryMB: {
@@ -453,7 +462,8 @@ export function createApi(database, indexer, queryPool = null, { zoektClient = n
ingestCounts: watcherState.ingestCounts,
projectFreshness,
serviceVersion: SERVICE_VERSION,
- serviceGitHash: SERVICE_GIT_HASH
+ serviceGitHash: SERVICE_GIT_HASH,
+ serviceGitTimestamp: SERVICE_GIT_TIMESTAMP
});
});
diff --git a/src/setup-gui.js b/src/setup-gui.js
index 3c8efd1..d9ca122 100644
--- a/src/setup-gui.js
+++ b/src/setup-gui.js
@@ -20,6 +20,59 @@ const DOCKER_COMPOSE_PATH = join(ROOT, 'docker-compose.yml');
const PUBLIC_DIR = join(ROOT, 'public');
const PORT = parseInt(process.argv[2]) || 3846;
+// Track spawned watcher PIDs so we can kill them directly (no heartbeat delay)
+// Map
+const watcherProcesses = new Map();
+
+/**
+ * Force-kill a process by PID. On Windows, uses taskkill /F which reliably
+ * terminates detached processes (process.kill + SIGTERM is unreliable on Windows).
+ */
+function killPid(pid) {
+ try {
+ if (process.platform === 'win32') {
+ execSync(`taskkill /F /PID ${pid} 2>nul`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
+ } else {
+ process.kill(pid, 'SIGTERM');
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Find and kill watcher-client.js processes for a workspace by scanning OS process list.
+ * This catches orphaned watchers that survived a setup-gui restart (detached processes
+ * whose PIDs we no longer track).
+ * Returns the number of processes killed.
+ */
+function killWatcherProcesses(workspaceName) {
+ let killed = 0;
+ try {
+ // wmic CSV format: "hostname,CommandLine,ProcessId" — PID is the last number on each line
+ const cmd = process.platform === 'win32'
+ ? 'wmic process where "name=\'node.exe\'" get ProcessId,CommandLine /format:csv 2>nul'
+ : 'ps aux';
+ const output = execSync(cmd, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
+ const needle = `watcher-client.js --workspace ${workspaceName}`;
+ for (const line of output.split('\n')) {
+ if (!line.includes(needle)) continue;
+ // Extract PID: last number on the line (works for both wmic CSV and ps aux)
+ const pidMatch = line.match(/(\d+)\s*$/);
+ const pid = pidMatch ? parseInt(pidMatch[1], 10) : 0;
+ if (!pid || pid === process.pid) continue;
+ if (killPid(pid)) {
+ killed++;
+ console.log(`[Setup] Killed orphan watcher for ${workspaceName} (PID ${pid})`);
+ }
+ }
+ } catch (err) {
+ console.warn(`[Setup] Process scan failed: ${err.message}`);
+ }
+ return killed;
+}
+
// ── Utilities ──────────────────────────────────────────────
function fwd(path) {
@@ -852,6 +905,14 @@ route('DELETE', '/api/workspaces/:name', async (req, res, params) => {
return;
}
+ // Kill the watcher before removing the container (tracked PIDs + orphan scan)
+ const tracked = watcherProcesses.get(name);
+ if (tracked) {
+ killPid(tracked.pid);
+ watcherProcesses.delete(name);
+ }
+ killWatcherProcesses(name);
+
// Stop and remove the Docker container before updating config
try {
if (process.platform === 'win32') {
@@ -1122,19 +1183,23 @@ route('POST', '/api/docker/build', (req, res) => {
const wslRoot = fwd(ROOT).replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`);
- // Resolve git hash at build time for version tracking inside the container
+ // Resolve git hash and timestamp at build time for version tracking inside the container
let gitHash = 'unknown';
try {
gitHash = execSync('git rev-parse --short HEAD', { cwd: ROOT, encoding: 'utf-8', timeout: 5000 }).trim();
} catch {}
+ let gitTimestamp = '0';
+ try {
+ gitTimestamp = execSync('git log -1 --format=%ct HEAD', { cwd: ROOT, encoding: 'utf-8', timeout: 5000 }).trim();
+ } catch {}
// Use spawn to bypass cmd.exe shell interpretation on Windows
// (cmd.exe splits on && inside single quotes, breaking the bash command)
let child;
if (process.platform === 'win32') {
- child = spawn('wsl', ['--', 'bash', '-c', `cd "${wslRoot}" && docker compose build --build-arg BUILD_GIT_HASH=${gitHash} 2>&1`]);
+ child = spawn('wsl', ['--', 'bash', '-c', `cd "${wslRoot}" && docker compose build --build-arg BUILD_GIT_HASH=${gitHash} --build-arg BUILD_GIT_TIMESTAMP=${gitTimestamp} 2>&1`]);
} else {
- child = spawn('docker', ['compose', 'build', '--build-arg', `BUILD_GIT_HASH=${gitHash}`], { cwd: ROOT });
+ child = spawn('docker', ['compose', 'build', '--build-arg', `BUILD_GIT_HASH=${gitHash}`, '--build-arg', `BUILD_GIT_TIMESTAMP=${gitTimestamp}`], { cwd: ROOT });
}
child.stdout?.on('data', data => {
@@ -1190,12 +1255,33 @@ route('POST', '/api/docker/start', async (req, res) => {
}
});
-// POST /api/docker/stop — Stop workspace container(s)
+// POST /api/docker/stop — Stop workspace container(s) and their watchers
route('POST', '/api/docker/stop', async (req, res) => {
try {
const body = await parseJsonBody(req);
const workspace = body.workspace;
+ // Kill watchers before stopping container (tracked PIDs + orphan scan)
+ if (workspace) {
+ const tracked = watcherProcesses.get(workspace);
+ if (tracked) {
+ killPid(tracked.pid);
+ watcherProcesses.delete(workspace);
+ }
+ killWatcherProcesses(workspace);
+ } else {
+ // Stopping all containers — kill all tracked watchers + scan for orphans
+ for (const [ws, tracked] of watcherProcesses) {
+ killPid(tracked.pid);
+ }
+ watcherProcesses.clear();
+ // Read workspace names to scan for orphans
+ try {
+ const wsData = JSON.parse(readFileSync(WORKSPACES_PATH, 'utf-8'));
+ for (const ws of Object.keys(wsData.workspaces || {})) killWatcherProcesses(ws);
+ } catch {}
+ }
+
const service = workspace ? ` ${workspace}` : '';
let output;
@@ -1248,9 +1334,13 @@ route('POST', '/api/watcher/start', async (req, res) => {
});
child.on('exit', (code, signal) => {
closeSync(logFd);
+ watcherProcesses.delete(wsName);
console.error(`[Setup] Watcher for ${wsName} exited (code=${code}, signal=${signal})`);
});
child.unref();
+
+ // Track PID for direct kill on stop (no heartbeat delay)
+ watcherProcesses.set(wsName, { pid: child.pid, logFd });
console.log(`[Setup] Started watcher for ${wsName} (PID ${child.pid}, port ${wsConfig.port}, log: ${logPath})`);
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1287,7 +1377,7 @@ route('GET', '/api/watcher/logs/:workspace', async (req, res, params) => {
}
});
-// POST /api/watcher/stop — Stop watcher by signaling via service heartbeat
+// POST /api/watcher/stop — Stop watcher: direct PID kill (instant) with heartbeat fallback
route('POST', '/api/watcher/stop', async (req, res) => {
try {
const body = await parseJsonBody(req);
@@ -1303,19 +1393,51 @@ route('POST', '/api/watcher/stop', async (req, res) => {
return;
}
- const serviceUrl = `http://127.0.0.1:${wsConfig.port}`;
+ let method = 'none';
+ let killed = 0;
- // Tell the service to signal watchers to shut down
- const resp = await fetch(`${serviceUrl}/internal/stop-watcher`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
- });
- const data = await resp.json();
- console.log(`[Setup] Watcher stop requested for ${wsName}: ${JSON.stringify(data)}`);
+ // Strategy 1: Direct PID kill from tracked map (instant)
+ const tracked = watcherProcesses.get(wsName);
+ if (tracked && !body.watcherId) {
+ if (killPid(tracked.pid)) {
+ killed++;
+ method = 'pid-kill';
+ console.log(`[Setup] Killed watcher for ${wsName} via tracked PID ${tracked.pid}`);
+ }
+ watcherProcesses.delete(wsName);
+ }
+
+ // Strategy 2: Scan OS process list for orphan watchers (survives setup-gui restart)
+ if (!body.watcherId) {
+ const orphans = killWatcherProcesses(wsName);
+ if (orphans > 0) {
+ killed += orphans;
+ if (method === 'none') method = 'process-scan';
+ }
+ }
+
+ // Strategy 3: Heartbeat-based shutdown (for specific watcherId, or as last resort)
+ if (method === 'none' || body.watcherId) {
+ try {
+ const serviceUrl = `http://127.0.0.1:${wsConfig.port}`;
+ const resp = await fetch(`${serviceUrl}/internal/stop-watcher`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ watcherId: body.watcherId || undefined })
+ });
+ const data = await resp.json();
+ if (method === 'none') method = 'heartbeat';
+ console.log(`[Setup] Watcher stop requested for ${wsName} via heartbeat: ${JSON.stringify(data)}`);
+ } catch (err) {
+ if (method === 'none') {
+ console.warn(`[Setup] Could not reach service for ${wsName} to stop watcher: ${err.message}`);
+ method = 'unreachable';
+ }
+ }
+ }
res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: true, workspace: wsName }));
+ res.end(JSON.stringify({ ok: true, workspace: wsName, method, killed }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
diff --git a/src/watcher/watcher-client.js b/src/watcher/watcher-client.js
index d106731..3720405 100644
--- a/src/watcher/watcher-client.js
+++ b/src/watcher/watcher-client.js
@@ -109,6 +109,10 @@ try {
try {
watcherGitHash = execSync('git rev-parse --short HEAD', { cwd: join(import.meta.dirname, '..', '..'), encoding: 'utf-8' }).trim();
} catch {}
+let watcherGitTimestamp = 0;
+try {
+ watcherGitTimestamp = parseInt(execSync('git log -1 --format=%ct HEAD', { cwd: join(import.meta.dirname, '..', '..'), encoding: 'utf-8' }).trim(), 10) || 0;
+} catch {}
let totalFilesIngested = 0;
let totalAssetsIngested = 0;
let totalDeletes = 0;
@@ -117,6 +121,10 @@ let lastIngestTimestamp = null;
let lastReconcileTimestamp = null;
let nextReconcileTimestamp = null;
+// Per-project scan progress (sent in heartbeat for dashboard display)
+// { "ProjectName": { phase, discovered, processed, language } }
+let scanProgress = {};
+
// --- Utility functions ---
function findProjectForPath(filePath) {
@@ -392,11 +400,14 @@ async function reconcile(project) {
if (changed.length === 0 && deleted.length === 0) {
console.log(`${logPrefix} ${project.name}: up to date (${diskFiles.length} files, scan ${collectMs}ms)`);
+ scanProgress[project.name] = { phase: 'watching', discovered: diskFiles.length, processed: diskFiles.length, language };
continue;
}
console.log(`${logPrefix} ${project.name}: ${changed.length} changed, ${deleted.length} deleted (of ${diskFiles.length} on disk, scan ${collectMs}ms)`);
+ scanProgress[project.name] = { phase: 'reconciling', discovered: changed.length, processed: 0, language };
+
// Step 4: Send deletes
if (deleted.length > 0) {
for (let i = 0; i < deleted.length; i += BATCH_SIZE) {
@@ -416,6 +427,7 @@ async function reconcile(project) {
if (assets.length > 0) {
await postJson(`${SERVICE_URL}/internal/ingest`, { assets });
}
+ scanProgress[project.name].processed = i + batch.length;
if ((i + batch.length) % 5000 < BATCH_SIZE * 10) {
console.log(`${logPrefix} ${project.name}: ${i + batch.length}/${changed.length} assets reconciled`);
}
@@ -441,12 +453,15 @@ async function reconcile(project) {
pendingPost = null;
}
+ scanProgress[project.name].processed = i + batch.length;
if ((i + batch.length) % 500 < BATCH_SIZE) {
console.log(`${logPrefix} ${project.name}: ${i + batch.length}/${changed.length} files reconciled`);
}
}
if (pendingPost) await pendingPost;
}
+
+ scanProgress[project.name].phase = 'watching';
}
}
@@ -466,6 +481,8 @@ async function fullScan(languages) {
const collectMs = (performance.now() - collectStart).toFixed(0);
console.log(`${logPrefix} Collected ${files.length} files from ${project.name} (${collectMs}ms)`);
+ scanProgress[project.name] = { phase: 'scanning', discovered: files.length, processed: 0, language: project.language };
+
if (project.language === 'content') {
// Asset batches
for (let i = 0; i < files.length; i += BATCH_SIZE * 10) {
@@ -477,6 +494,7 @@ async function fullScan(languages) {
if (assets.length > 0) {
await postJson(`${SERVICE_URL}/internal/ingest`, { assets });
}
+ scanProgress[project.name].processed = i + batch.length;
if ((i + batch.length) % 5000 < BATCH_SIZE * 10) {
console.log(`${logPrefix} ${project.name}: ${i + batch.length}/${files.length} assets`);
}
@@ -501,12 +519,15 @@ async function fullScan(languages) {
pendingPost = null;
}
+ scanProgress[project.name].processed = i + batch.length;
if ((i + batch.length) % 500 < BATCH_SIZE) {
console.log(`${logPrefix} ${project.name}: ${i + batch.length}/${files.length} files`);
}
}
if (pendingPost) await pendingPost;
}
+
+ scanProgress[project.name].phase = 'done';
}
}
@@ -651,6 +672,7 @@ async function main() {
watcherId,
version: watcherVersion,
gitHash: watcherGitHash,
+ gitTimestamp: watcherGitTimestamp,
startedAt: startupTimestamp,
watchedPaths: config.projects.reduce((n, p) => n + p.paths.length, 0),
projects: config.projects.map(p => ({
@@ -668,7 +690,8 @@ async function main() {
reconciliation: {
lastRunAt: lastReconcileTimestamp,
nextRunAt: nextReconcileTimestamp
- }
+ },
+ scanProgress
});
// Check if service requested shutdown (e.g. Restart All from dashboard)
@@ -729,6 +752,15 @@ async function main() {
console.log(`${logPrefix} Reconciliation complete (${reconcileS}s)`);
}
+ // Mark all projects as watching now that initial scan/reconcile is done
+ for (const project of config.projects) {
+ if (!scanProgress[project.name]) {
+ scanProgress[project.name] = { phase: 'watching', discovered: 0, processed: 0, language: project.language };
+ } else if (scanProgress[project.name].phase === 'done') {
+ scanProgress[project.name].phase = 'watching';
+ }
+ }
+
let activeWatcher = startWatcher();
// --- Periodic reconciliation: catch missed file changes ---
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..008e206
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,12 @@
+@echo off
+:: Kill any existing setup-gui and watcher processes, then start fresh
+cd /d "%~dp0"
+
+echo Stopping old processes...
+wmic process where "name='node.exe' and CommandLine like '%%setup-gui.js%%'" call terminate >nul 2>&1
+wmic process where "name='node.exe' and CommandLine like '%%watcher-client.js%%'" call terminate >nul 2>&1
+timeout /t 1 /nobreak >nul
+
+echo Starting setup GUI on http://localhost:3846 ...
+start "" http://localhost:3846
+node src/setup-gui.js