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
54 changes: 53 additions & 1 deletion packages/codev/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { deepMerge, loadConfig, resolveProjectConfigPath } from '../lib/config.js';
import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js';

// Helpers
let tmpDir: string;
Expand Down Expand Up @@ -39,6 +39,12 @@ function writeGlobalConfig(config: Record<string, unknown>) {
fs.writeFileSync(path.join(globalCodevDir, 'config.json'), JSON.stringify(config, null, 2));
}

function writeLocalConfig(workspaceRoot: string, config: Record<string, unknown>) {
const dir = path.join(workspaceRoot, '.codev');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'config.local.json'), JSON.stringify(config, null, 2));
}

// =============================================================================
// deepMerge
// =============================================================================
Expand Down Expand Up @@ -112,6 +118,22 @@ describe('resolveProjectConfigPath', () => {
});
});

// =============================================================================
// resolveLocalConfigPath
// =============================================================================

describe('resolveLocalConfigPath', () => {
it('returns .codev/config.local.json path when it exists', () => {
writeLocalConfig(tmpDir, { shell: {} });
const result = resolveLocalConfigPath(tmpDir);
expect(result).toBe(path.join(tmpDir, '.codev', 'config.local.json'));
});

it('returns null when no local config exists', () => {
expect(resolveLocalConfigPath(tmpDir)).toBeNull();
});
});

// =============================================================================
// loadConfig
// =============================================================================
Expand Down Expand Up @@ -201,4 +223,34 @@ describe('loadConfig', () => {
const config = loadConfig(tmpDir);
expect(config.porch?.checks?.lint).toEqual({ skip: true });
});

it('layer 5: .codev/config.local.json overrides defaults when project config absent', () => {
writeLocalConfig(tmpDir, {
shell: { architect: 'local-only-architect' },
});
const config = loadConfig(tmpDir);
expect(config.shell?.architect).toBe('local-only-architect');
expect(config.shell?.builder).toBe('claude'); // default preserved
});

it('layer 5: local overrides project, non-overlapping project keys survive', () => {
writeProjectConfig(tmpDir, {
shell: { architect: 'project-architect', builder: 'project-builder' },
});
writeLocalConfig(tmpDir, {
shell: { architect: 'local-architect' },
});
const config = loadConfig(tmpDir);
expect(config.shell?.architect).toBe('local-architect'); // local wins
expect(config.shell?.builder).toBe('project-builder'); // project survives
});

it('layer 5: missing config.local.json is a no-op', () => {
writeProjectConfig(tmpDir, {
shell: { architect: 'project-architect' },
});
// no writeLocalConfig — file absent
const config = loadConfig(tmpDir);
expect(config.shell?.architect).toBe('project-architect');
});
});
5 changes: 3 additions & 2 deletions packages/codev/src/agent-farm/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const getWorktreeConfigMock = vi.fn(() => ({
symlinks: [],
postSpawn: [],
devCommand: 'pnpm dev',
devUrls: [],
}));
vi.mock('../utils/index.js', () => ({
getConfig: () => ({ workspaceRoot: '/proj' }),
Expand Down Expand Up @@ -94,7 +95,7 @@ beforeEach(() => {
findBuilderByIdMock.mockReturnValue(builder);
listTerminalsMock.mockResolvedValue([]);
killTerminalMock.mockResolvedValue(true);
getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev' });
getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev', devUrls: [] });
});

// ─── Tests ──────────────────────────────────────────────────────────────
Expand All @@ -110,7 +111,7 @@ describe('afx dev — validation', () => {
});

it('errors when worktree.devCommand is unset', async () => {
getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null });
getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] });
await expect(dev({ builderId: 'spir-42' })).rejects.toThrow(/No worktree\.devCommand configured/);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ vi.mock('../../lib/forge.js', () => ({
// Mock the harness resolution to return claude harness by default
import { CLAUDE_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js';
const getBuilderHarnessMock = vi.fn(() => CLAUDE_HARNESS);
const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null }));
const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] }));
vi.mock('../utils/config.js', () => ({
getBuilderHarness: (...args: unknown[]) => getBuilderHarnessMock(...args),
getWorktreeConfig: (...args: unknown[]) => getWorktreeConfigMock(...args),
Expand Down Expand Up @@ -882,6 +882,7 @@ describe('spawn-worktree', () => {
symlinks: ['.env.local', 'packages/*/.env'],
postSpawn: [],
devCommand: null,
devUrls: [],
});
// Hardcoded section (.env / .codev/config.json) — both absent
vi.mocked(existsSync)
Expand Down Expand Up @@ -914,6 +915,7 @@ describe('spawn-worktree', () => {
symlinks: ['.env.local'],
postSpawn: [],
devCommand: null,
devUrls: [],
});
// Hardcoded section: both absent so no calls there
vi.mocked(existsSync)
Expand Down
39 changes: 39 additions & 0 deletions packages/codev/src/agent-farm/servers/tower-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
serveStaticFile,
} from './tower-utils.js';
import { handleTunnelEndpoint } from './tower-tunnel.js';
import { getWorktreeConfig } from '../utils/config.js';
import { ensureWorktreeConfigWatcher } from './worktree-config-watcher.js';
import { hasTeam, loadTeamMembers, loadMessages, type TeamMember, type TeamMessage } from '../../lib/team.js';
import { fetchTeamGitHubData, type TeamMemberGitHubData } from '../../lib/team-github.js';
import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js';
Expand Down Expand Up @@ -147,6 +149,7 @@ const ROUTES: Record<string, RouteEntry> = {
'GET /api/status': (_req, res) => handleStatus(res),
'GET /api/overview': (_req, res, url, ctx) => handleOverview(res, url, undefined, ctx),
'GET /api/issue': (_req, res, url) => handleIssueView(res, url),
'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url),
'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url),
'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx),
'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx),
Expand Down Expand Up @@ -817,6 +820,42 @@ async function handleIssueView(res: http.ServerResponse, url: URL): Promise<void
res.end(JSON.stringify(issue));
}

/**
* GET /api/worktree-config — returns the canonical `ResolvedWorktreeConfig`
* for the requested workspace (defaults / cache / global / project /
* project-local, deep-merged per `lib/config.ts:loadConfig`). This is
* the single source of truth for any client that needs to act on
* worktree config (currently the VSCode extension's "Open Dev URL"
* surface; the dashboard is welcome to use it too).
*
* Side effect: lazily installs a directory watcher on the workspace's
* `.codev/` so any subsequent edit to `config.json` /
* `config.local.json` fans out a `worktree-config-updated` SSE event
* — clients refetch via this same endpoint and re-render.
*/
function handleWorktreeConfigView(res: http.ServerResponse, url: URL): void {
let workspaceRoot = url.searchParams.get('workspace');
if (!workspaceRoot) {
const knownPaths = getKnownWorkspacePaths();
workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null;
}
if (!workspaceRoot) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing workspace' }));
return;
}
try {
const config = getWorktreeConfig(workspaceRoot);
ensureWorktreeConfigWatcher(workspaceRoot);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to resolve worktree config: ${message}` }));
}
}

function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void {
overviewCache.invalidate();
// Bugfix #388: Broadcast SSE event so all connected dashboard clients
Expand Down
10 changes: 10 additions & 0 deletions packages/codev/src/agent-farm/servers/tower-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from './tower-websocket.js';
import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js';
import type { RouteContext } from './tower-routes.js';
import { setWorktreeConfigNotifier, stopAllWorktreeConfigWatchers } from './worktree-config-watcher.js';
import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js';
import { validateHost } from '../utils/server-utils.js';

Expand Down Expand Up @@ -159,6 +160,9 @@ async function gracefulShutdown(signal: string): Promise<void> {
// 6. Disconnect tunnel (Spec 0097 Phase 4 / Spec 0105 Phase 2)
shutdownTunnel();

// 6b. Close per-workspace .codev/config(.local).json watchers.
stopAllWorktreeConfigWatchers();

// 7. Tear down instance module (Spec 0105 Phase 3)
shutdownInstances();

Expand Down Expand Up @@ -319,6 +323,12 @@ const routeCtx: RouteContext = {
},
};

// Wire the broadcast function into the worktree config watcher so file
// edits to .codev/config{,.local}.json fan out as
// `worktree-config-updated` SSE events. The actual watcher is installed
// lazily by the /api/worktree-config route handler on first request.
setWorktreeConfigNotifier(broadcastNotification);

// ============================================================================
// Create server — delegates all HTTP handling to tower-routes.ts
// ============================================================================
Expand Down
81 changes: 81 additions & 0 deletions packages/codev/src/agent-farm/servers/worktree-config-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Per-workspace file watcher for `.codev/config.json` and
* `.codev/config.local.json`. Lazily installed by the
* `/api/worktree-config` route handler on first request, then persists
* for the Tower process lifetime. On each detected change it fans out
* a `worktree-config-updated` SSE event so subscribed clients (the
* VSCode extension, the dashboard) can refetch via the route and
* re-render.
*
* Pattern follows `tower-tunnel.ts:startConfigWatcher` (which watches
* `~/.codev/cloud.json` for OAuth credential changes) — `node:fs.watch`
* on the parent directory, filename filter, short debounce to coalesce
* the multiple events that fire per save.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';

type NotifyFn = (notification: {
type: string;
title: string;
body: string;
workspace?: string;
}) => void;

const TARGET_FILES = new Set(['config.json', 'config.local.json']);
const DEBOUNCE_MS = 50;

const watchers = new Map<string, fs.FSWatcher>();
const debounces = new Map<string, NodeJS.Timeout>();
let notify: NotifyFn | undefined;

/**
* Wire the broadcast function once at Tower startup. Subsequent calls
* to `ensureWorktreeConfigWatcher` will use this notifier when files
* change.
*/
export function setWorktreeConfigNotifier(fn: NotifyFn): void {
notify = fn;
}

/**
* Lazily install (or no-op if already installed) a file watcher for
* `<workspacePath>/.codev/{config.json,config.local.json}`. Safe to
* call on every route hit.
*/
export function ensureWorktreeConfigWatcher(workspacePath: string): void {
if (watchers.has(workspacePath)) { return; }
const dir = path.join(workspacePath, '.codev');
try {
const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
if (!filename || !TARGET_FILES.has(filename)) { return; }
const prev = debounces.get(workspacePath);
if (prev) { clearTimeout(prev); }
debounces.set(
workspacePath,
setTimeout(() => {
debounces.delete(workspacePath);
notify?.({
type: 'worktree-config-updated',
title: 'Worktree config changed',
body: JSON.stringify({ workspace: workspacePath }),
workspace: workspacePath,
});
}, DEBOUNCE_MS),
);
});
watcher.on('error', () => { /* benign — dir may be removed mid-watch */ });
watchers.set(workspacePath, watcher);
} catch {
// `.codev/` may not exist yet; the next ensure call will retry.
}
}

/** Test / shutdown helper — close every watcher and clear pending debounces. */
export function stopAllWorktreeConfigWatchers(): void {
for (const t of debounces.values()) { clearTimeout(t); }
debounces.clear();
for (const w of watchers.values()) { try { w.close(); } catch { /* benign */ } }
watchers.clear();
}
13 changes: 13 additions & 0 deletions packages/codev/src/agent-farm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,19 @@ export interface UserConfig {
* Example: 'pnpm dev'.
*/
devCommand?: string;
/**
* Dev URLs the running app(s) listen on — surfaced as one
* workspace-view row per entry in VSCode (`label` = row text,
* `url` = what opens in the default browser). The palette command
* `Codev: Open Dev URL` shows a QuickPick when invoked without a
* specific target. Both fields are required; entries missing
* either are silently filtered out.
* Example:
* [{ "label": "App", "url": "http://localhost:3000" },
* { "label": "API", "url": "http://localhost:3001" },
* { "label": "Admin", "url": "http://localhost:8080/admin" }]
*/
devUrls?: Array<{ label: string; url: string }>;
};
}

Expand Down
37 changes: 25 additions & 12 deletions packages/codev/src/agent-farm/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { getSkeletonDir } from '../../lib/skeleton.js';
import { loadConfig } from '../../lib/config.js';
import type { CodevConfig } from '../../lib/config.js';
import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js';
import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types';

// Re-export so existing internal callers that import the resolved types
// from this module keep working. The canonical home is now
// @cluesmith/codev-types (these cross HTTP via /api/worktree-config).
export type { ResolvedWorktreeConfig, WorktreeDevUrl };

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -269,18 +275,10 @@ export function getBuilderHarness(workspaceRoot?: string): HarnessProvider {
);
}

/**
* Resolved view of the `worktree` config block with defaults applied.
* Unset fields collapse to empty / null so callers don't have to branch.
*/
export interface ResolvedWorktreeConfig {
/** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */
symlinks: string[];
/** Shell commands to run in each worktree after creation. `[]` when unset. */
postSpawn: string[];
/** Command for `afx dev <builder-id>`. `null` when unset. */
devCommand: string | null;
}
// ResolvedWorktreeConfig + WorktreeDevUrl now live in
// @cluesmith/codev-types (they cross HTTP via /api/worktree-config).
// Re-exported from this module at the top of the file so existing
// internal callers keep working.

/**
* Load the `worktree` block from .codev/config.json, applying defaults.
Expand All @@ -296,9 +294,24 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi
symlinks: w?.symlinks ?? [],
postSpawn: w?.postSpawn ?? [],
devCommand: w?.devCommand ?? null,
devUrls: resolveDevUrls(w),
};
}

/**
* Filter malformed entries (missing/empty `label` or `url`). Both
* fields are mandatory by schema — no default-label fallback.
*/
function resolveDevUrls(w: { devUrls?: Array<{ label?: string; url?: string }> } | undefined): WorktreeDevUrl[] {
if (!Array.isArray(w?.devUrls)) { return []; }
return w.devUrls
.map(e => ({
label: typeof e?.label === 'string' ? e.label.trim() : '',
url: typeof e?.url === 'string' ? e.url.trim() : '',
}))
.filter(e => e.label && e.url);
}

/**
* Build configuration for the current project
*/
Expand Down
Loading
Loading