Files
Codeman/test/routes/remote-discover-routes.test.ts
Aamer Akhter fb013e9de0 COD-105 discover + attach existing remote tmux sessions (detach-not-kill)
Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already
running on a remote host (created by the remote's own Codeman or another
instance) and attach to one this Codeman didn't launch, with detach-not-kill
ownership for non-owned sessions.

- remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws)
  + pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits
  on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a
  real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote
  now marks the COD-104 launch path owned:true.
- tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand);
  buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains
  a detach-not-kill early return for non-owned remote sessions: tears down only the
  LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote
  kill-session.
- types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName.
- schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName};
  fixed a pre-existing no-useless-escape lint error in the jumpHost regex.
- case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery).
- session-routes.ts: attachRemoteSession create path -> non-owned session.
- UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions"
  button + Attach action (owned:false). No auto-discover.

Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared
view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests:
parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
2026-07-17 15:49:24 -04:00

114 lines
4.6 KiB
TypeScript

/**
* @fileoverview COD-105 — GET /api/remote-hosts/:hostId/sessions discovery endpoint.
*
* The endpoint reads the saved host config by id, runs listRemoteCodemanSessions
* (ssh-guarded under VITEST), and returns the discovered sessions in the
* ApiResponse envelope. We mock the remote-hosts module so the test controls the
* host record and the session list WITHOUT any real ssh / filesystem.
*
* Port: N/A (app.inject()).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import type { RemoteHost, RemoteSessionInfo } from '../../src/types.js';
// Mock the remote-hosts module: control readRemoteHosts + listRemoteCodemanSessions.
const mockHosts: RemoteHost[] = [];
let mockSessions: RemoteSessionInfo[] = [];
let lastListArg: unknown = undefined;
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
return {
...actual,
readRemoteHosts: vi.fn(async () => mockHosts),
readRemoteCases: vi.fn(async () => []),
listRemoteCodemanSessions: vi.fn(async (remote: unknown) => {
lastListArg = remote;
return mockSessions;
}),
};
});
vi.mock('../../src/templates/claude-md.js', () => ({
generateClaudeMd: vi.fn(() => '# CLAUDE.md'),
}));
vi.mock('../../src/hooks-config.js', () => ({ writeHooksConfig: vi.fn(async () => {}) }));
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
async function createHarness(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
const ctx = createMockRouteContext();
registerCaseRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
return app;
}
describe('COD-105 GET /api/remote-hosts/:hostId/sessions', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await createHarness();
mockHosts.length = 0;
mockSessions = [];
lastListArg = undefined;
});
afterEach(async () => {
await app.close();
});
it('returns discovered sessions for a known host in the envelope', async () => {
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht', port: 2222 });
mockSessions = [{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }];
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.sessions).toEqual([{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }]);
// The host config (incl. port) was threaded to the discovery call.
expect((lastListArg as { host?: string; port?: number }).host).toBe('1.2.3.4');
expect((lastListArg as { port?: number }).port).toBe(2222);
});
it('404s when the host id is unknown', async () => {
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/nope/sessions' });
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe(ApiErrorCode.NOT_FOUND);
});
it('returns an empty list (not an error) when no sessions are discovered', async () => {
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht' });
mockSessions = [];
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.sessions).toEqual([]);
});
});