Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/web/public/session-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -2709,7 +2709,10 @@ Object.assign(CodemanApp.prototype, {
// Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled');
imageWatcherToggle.checked = session.imageWatcherEnabled ?? true;
// The server never watches a remote session's directory (it is on another host), so the row is hidden.
imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : '';
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;

// Populate session name input with prefix/suffix split
Expand Down
5 changes: 5 additions & 0 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3780,6 +3780,11 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// A remote session's workingDir is a path on the remote host; the watcher would watch it on the
// LOCAL disk (a missing folder, or a same-named local one whose images are not the session's).
if (body.enabled && session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
}

if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
Expand Down
5 changes: 3 additions & 2 deletions src/web/routes/system-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1183,9 +1183,10 @@ export function registerSystemRoutes(

// Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
// workingDir is a path on the remote host, not on this disk)
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
if (session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2067,8 +2067,10 @@ export class WebServer extends EventEmitter {
session.ralphTracker.setWorkingDir(session.workingDir);
}

// Start watching for new images in this session's working directory (if enabled globally and per-session)
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
// Start watching for new images in this session's working directory (if enabled globally and per-session).
// A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
// disk, so it is skipped there (the per-session and app-wide toggles skip it too).
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}

Expand Down
190 changes: 190 additions & 0 deletions test/image-watcher-remote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* The image watcher must never watch a REMOTE session's working directory.
*
* A remote-SSH session's `workingDir` is a path on the remote host. The watcher
* (chokidar) watches it on the LOCAL disk: where nothing exists there it watches
* a missing folder, and where a same-named local folder does exist it reports that
* folder's images as the remote session's. Three places start a watch, and all
* three skip a remote session:
* - `WebServer.setupSessionListeners()` (create, respawn, restore, recovery);
* - `POST /api/sessions/:id/image-watcher` (the per-session toggle), which refuses
* to turn it on, the way the Git fetch settings route refuses a remote session;
* - `PUT /api/settings` turning the app-wide watcher on, which re-watches every
* session that has it enabled.
*
* Spies on the real `imageWatcher` singleton, so all three modules see the spy.
* Port: none (app.inject(), and a WebServer that is never started).
*/
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';

import { imageWatcher } from '../src/image-watcher.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../src/web/routes/system-routes.js';

const WORKSPACE = join(homedir(), '.codeman-test-image-watcher-remote');
// A path on the remote host: nothing exists there locally.
const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure';
const REMOTE = {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: REMOTE_PATH,
owned: true,
};

let watchSession: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
watchSession = vi.spyOn(imageWatcher, 'watchSession').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
imageWatcher.stop();
});

describe('setupSessionListeners()', () => {
interface ServerInternals {
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
isImageWatcherEnabled(): Promise<boolean>;
}
const SESSION_ID = 'a1b2c3d4e5f60718';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;

beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});

afterEach(async () => {
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});

afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});

async function wire(remote: boolean) {
const session = new Session({
id: SESSION_ID,
workingDir: remote ? REMOTE_PATH : WORKSPACE,
mode: 'claude',
name: remote ? 'remote session' : 'local session',
mux,
useMux: true,
...(remote ? { remote: REMOTE } : {}),
});
session.imageWatcherEnabled = true;
vi.spyOn(internals, 'isImageWatcherEnabled').mockResolvedValue(true);
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
}

it('does not watch a remote session working directory', async () => {
await wire(true);
expect(watchSession).not.toHaveBeenCalled();
});

it('still watches a local session working directory', async () => {
await wire(false);
expect(watchSession).toHaveBeenCalledWith(SESSION_ID, WORKSPACE);
});
});

describe('POST /api/sessions/:id/image-watcher', () => {
let harness: RouteTestHarness;

beforeEach(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});

afterEach(async () => {
await harness.app.close();
});

const post = (enabled: boolean) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/image-watcher`,
payload: { enabled },
});

it('refuses to turn it on for a remote session, storing nothing', async () => {
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.remote = REMOTE;
session.workingDir = REMOTE_PATH;
const res = await post(true);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(watchSession).not.toHaveBeenCalled();
expect(session.imageWatcherEnabled).not.toBe(true);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});

it('still turns it off for a remote session', async () => {
(harness.ctx._session as unknown as Record<string, unknown>).remote = REMOTE;
const unwatchSession = vi.spyOn(imageWatcher, 'unwatchSession');
const res = await post(false);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { imageWatcherEnabled: false } });
expect(unwatchSession).toHaveBeenCalledWith(harness.ctx._sessionId);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});

it('still turns it on for a local session', async () => {
const res = await post(true);
expect(res.statusCode).toBe(200);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});

describe('PUT /api/settings turning the app-wide watcher on', () => {
let harness: RouteTestHarness;

beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
});

afterEach(async () => {
await harness.app.close();
});

it('re-watches the local sessions that have it enabled, never a remote one', async () => {
const local = harness.ctx._session as unknown as Record<string, unknown>;
local.imageWatcherEnabled = true;
harness.ctx.sessions.set('remote-session', {
...harness.ctx._session,
id: 'remote-session',
workingDir: REMOTE_PATH,
imageWatcherEnabled: true,
remote: REMOTE,
} as unknown as typeof harness.ctx._session);
expect(imageWatcher.isRunning()).toBe(false);

const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { imageWatcherEnabled: true },
});

expect(res.statusCode).toBe(200);
expect(imageWatcher.isRunning()).toBe(true);
expect(watchSession).toHaveBeenCalledTimes(1);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});
Loading