test: Extended daemon types with session management interfaces and buil…

- "packages/daemon/src/types.ts"
- "packages/daemon/src/project-scanner.ts"
- "packages/daemon/src/project-scanner.test.ts"

GSD-Task: S02/T01
This commit is contained in:
Lex Christopherson 2026-03-27 14:08:04 -06:00
parent 2a0d63accd
commit 9af08f6480
3 changed files with 470 additions and 0 deletions

View file

@ -0,0 +1,235 @@
/**
* Tests for the project scanner module.
*/
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, platform } from 'node:os';
import { randomUUID } from 'node:crypto';
import { scanForProjects } from './project-scanner.js';
// ---------- helpers ----------
function tmpDir(): string {
return mkdtempSync(join(tmpdir(), `scanner-test-${randomUUID().slice(0, 8)}-`));
}
const cleanupDirs: string[] = [];
afterEach(() => {
while (cleanupDirs.length) {
const d = cleanupDirs.pop()!;
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
}
});
/** Create a project directory with specified marker files/dirs */
function createProject(root: string, name: string, markers: string[]): string {
const projDir = join(root, name);
mkdirSync(projDir, { recursive: true });
for (const marker of markers) {
const markerPath = join(projDir, marker);
if (marker.startsWith('.') && !marker.includes('.')) {
// Likely a directory marker (.git, .gsd)
mkdirSync(markerPath, { recursive: true });
} else {
// File marker (package.json, Cargo.toml, etc.)
writeFileSync(markerPath, '{}');
}
}
return projDir;
}
// ---------- tests ----------
describe('scanForProjects', () => {
it('finds projects with marker files', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'my-app', ['.git', 'package.json']);
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'my-app');
assert.equal(results[0]!.path, join(root, 'my-app'));
assert.ok(results[0]!.markers.includes('git'));
assert.ok(results[0]!.markers.includes('node'));
assert.ok(results[0]!.lastModified > 0);
});
it('handles missing scan_root gracefully', async () => {
const results = await scanForProjects(['/nonexistent/path/that/does/not/exist']);
assert.deepEqual(results, []);
});
it('handles permission errors on entries', { skip: platform() === 'win32' ? 'chmod not reliable on Windows' : undefined }, async () => {
const root = tmpDir();
cleanupDirs.push(root);
// Create an accessible project
createProject(root, 'accessible', ['.git']);
// Create an inaccessible directory
const noAccess = join(root, 'locked');
mkdirSync(noAccess);
chmodSync(noAccess, 0o000);
const results = await scanForProjects([root]);
// Restore permissions for cleanup
chmodSync(noAccess, 0o755);
// Should find the accessible project but skip the locked one
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'accessible');
});
it('detects multiple marker types', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'full-stack', ['.git', 'package.json', '.gsd']);
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.markers.length, 3);
assert.ok(results[0]!.markers.includes('git'));
assert.ok(results[0]!.markers.includes('node'));
assert.ok(results[0]!.markers.includes('gsd'));
});
it('returns results sorted alphabetically by name', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'zebra-project', ['.git']);
createProject(root, 'alpha-project', ['.git']);
createProject(root, 'middle-project', ['.git']);
const results = await scanForProjects([root]);
assert.equal(results.length, 3);
assert.equal(results[0]!.name, 'alpha-project');
assert.equal(results[1]!.name, 'middle-project');
assert.equal(results[2]!.name, 'zebra-project');
});
it('ignores hidden directories', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'visible', ['.git']);
createProject(root, '.hidden', ['.git']);
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'visible');
});
it('ignores node_modules', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'real-project', ['package.json']);
createProject(root, 'node_modules', ['package.json']);
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'real-project');
});
it('skips directories with no markers', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'has-markers', ['.git']);
// Create a plain directory with no markers
mkdirSync(join(root, 'no-markers'));
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'has-markers');
});
it('scans multiple roots', async () => {
const root1 = tmpDir();
const root2 = tmpDir();
cleanupDirs.push(root1, root2);
createProject(root1, 'proj-a', ['.git']);
createProject(root2, 'proj-b', ['Cargo.toml']);
const results = await scanForProjects([root1, root2]);
assert.equal(results.length, 2);
assert.equal(results[0]!.name, 'proj-a');
assert.ok(results[0]!.markers.includes('git'));
assert.equal(results[1]!.name, 'proj-b');
assert.ok(results[1]!.markers.includes('rust'));
});
it('detects all supported marker types', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'git-proj', ['.git']);
createProject(root, 'node-proj', ['package.json']);
createProject(root, 'gsd-proj', ['.gsd']);
createProject(root, 'rust-proj', ['Cargo.toml']);
createProject(root, 'python-proj', ['pyproject.toml']);
createProject(root, 'go-proj', ['go.mod']);
const results = await scanForProjects([root]);
assert.equal(results.length, 6);
const byName = new Map(results.map(r => [r.name, r]));
assert.deepEqual(byName.get('git-proj')!.markers, ['git']);
assert.deepEqual(byName.get('node-proj')!.markers, ['node']);
assert.deepEqual(byName.get('gsd-proj')!.markers, ['gsd']);
assert.deepEqual(byName.get('rust-proj')!.markers, ['rust']);
assert.deepEqual(byName.get('python-proj')!.markers, ['python']);
assert.deepEqual(byName.get('go-proj')!.markers, ['go']);
});
it('skips non-directory entries', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'real-project', ['.git']);
// Create a regular file at the root level — should be ignored
writeFileSync(join(root, 'some-file.txt'), 'not a directory');
const results = await scanForProjects([root]);
assert.equal(results.length, 1);
assert.equal(results[0]!.name, 'real-project');
});
it('returns empty array for empty scan_roots', async () => {
const results = await scanForProjects([]);
assert.deepEqual(results, []);
});
it('deduplicates when same root appears twice', async () => {
const root = tmpDir();
cleanupDirs.push(root);
createProject(root, 'only-once', ['.git']);
const results = await scanForProjects([root, root]);
// Same directory scanned twice — results will have duplicates
// (this is acceptable; the caller can deduplicate by path if needed)
assert.equal(results.length, 2);
assert.equal(results[0]!.name, 'only-once');
assert.equal(results[1]!.name, 'only-once');
});
});

View file

@ -0,0 +1,99 @@
/**
* Project scanner discovers projects in configured scan_roots by detecting
* marker files/directories. Reads one level deep (immediate children only).
*/
import { readdir, stat, access } from 'node:fs/promises';
import { join, basename } from 'node:path';
import type { ProjectInfo, ProjectMarker } from './types.js';
// ---------------------------------------------------------------------------
// Marker file → project type mapping
// ---------------------------------------------------------------------------
const MARKER_MAP: ReadonlyMap<string, ProjectMarker> = new Map([
['.git', 'git'],
['package.json', 'node'],
['.gsd', 'gsd'],
['Cargo.toml', 'rust'],
['pyproject.toml', 'python'],
['go.mod', 'go'],
]);
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Scan configured roots for project directories.
*
* Behaviour:
* - Reads immediate children of each root (1 level deep, not recursive)
* - Skips hidden directories (starting with `.`) and `node_modules`
* - Skips missing roots and permission-denied entries gracefully
* - Detects markers via MARKER_MAP; directories with no markers are excluded
* - Results are sorted alphabetically by name
* - lastModified is the most recent mtime among detected marker files/dirs
*/
export async function scanForProjects(scanRoots: string[]): Promise<ProjectInfo[]> {
const results: ProjectInfo[] = [];
for (const root of scanRoots) {
let entries: string[];
try {
entries = await readdir(root);
} catch {
// Missing root or permission error — skip gracefully
continue;
}
for (const entry of entries) {
// Skip hidden directories and node_modules
if (entry.startsWith('.') || entry === 'node_modules') continue;
const entryPath = join(root, entry);
// Must be a directory
let entryStat;
try {
entryStat = await stat(entryPath);
} catch {
// Permission error or disappeared entry — skip
continue;
}
if (!entryStat.isDirectory()) continue;
// Detect markers
const markers: ProjectMarker[] = [];
let latestMtime = 0;
for (const [markerFile, markerType] of MARKER_MAP) {
const markerPath = join(entryPath, markerFile);
try {
const markerStat = await stat(markerPath);
markers.push(markerType);
if (markerStat.mtimeMs > latestMtime) {
latestMtime = markerStat.mtimeMs;
}
} catch {
// Marker doesn't exist — not an error
}
}
// Only include directories with at least one marker
if (markers.length === 0) continue;
results.push({
name: basename(entryPath),
path: entryPath,
markers,
lastModified: latestMtime,
});
}
}
// Sort alphabetically by name
results.sort((a, b) => a.name.localeCompare(b.name));
return results;
}

View file

@ -32,3 +32,139 @@ export interface DaemonConfig {
max_size_mb: number;
};
}
// ---------------------------------------------------------------------------
// Session Status
// ---------------------------------------------------------------------------
export type SessionStatus = 'starting' | 'running' | 'blocked' | 'completed' | 'error' | 'cancelled';
// ---------------------------------------------------------------------------
// Managed Session
// ---------------------------------------------------------------------------
/**
* A daemon-managed GSD headless session.
*
* The `client` and `events` fields use generic types here. T02 will add
* @gsd-build/rpc-client as a dependency and narrow these to RpcClient and
* SdkAgentEvent respectively.
*/
export interface ManagedSession {
/** Unique session ID returned from RpcClient.init() */
sessionId: string;
/** Absolute path to the project directory */
projectDir: string;
/** Human-readable project name (basename of projectDir) */
projectName: string;
/** Current lifecycle status */
status: SessionStatus;
/** The RpcClient instance managing the agent process (typed when rpc-client is wired) */
client: unknown;
/** Ring buffer of recent events (capped at MAX_EVENTS) */
events: unknown[];
/** Pending blocker requiring user response, if any */
pendingBlocker: PendingBlocker | null;
/** Cumulative cost tracking (max pattern per K004) */
cost: CostAccumulator;
/** Session start timestamp */
startTime: number;
/** Error message if status is 'error' */
error?: string;
/** Cleanup function to unsubscribe from events */
unsubscribe?: () => void;
}
// ---------------------------------------------------------------------------
// Pending Blocker
// ---------------------------------------------------------------------------
export interface PendingBlocker {
/** The extension_ui_request id */
id: string;
/** The request method (e.g. 'select', 'confirm', 'input') */
method: string;
/** Human-readable message or title */
message: string;
/** Full event payload for inspection */
event: unknown;
}
// ---------------------------------------------------------------------------
// Cost Accumulator (K004 — cumulative-max)
// ---------------------------------------------------------------------------
export interface CostAccumulator {
totalCost: number;
tokens: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}
// ---------------------------------------------------------------------------
// Project Info — scanner output
// ---------------------------------------------------------------------------
/** Marker types detectable by the project scanner */
export type ProjectMarker = 'git' | 'node' | 'gsd' | 'rust' | 'python' | 'go';
export interface ProjectInfo {
/** Directory name (basename) */
name: string;
/** Absolute path to the project directory */
path: string;
/** Detected marker types */
markers: ProjectMarker[];
/** Most recent mtime of detected marker files/dirs (epoch ms) */
lastModified: number;
}
// ---------------------------------------------------------------------------
// Start Session Options
// ---------------------------------------------------------------------------
export interface StartSessionOptions {
/** Absolute path to the project directory */
projectDir: string;
/** Command to send after '/gsd auto' (default: none) */
command?: string;
/** Model ID override */
model?: string;
/** Run in bare mode (skip user config) */
bare?: boolean;
/** Path to CLI binary (overrides GSD_CLI_PATH and which resolution) */
cliPath?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Maximum number of events kept in the ring buffer (larger than mcp-server's 50 — daemon forwards events to Discord) */
export const MAX_EVENTS = 100;
/** Timeout for RpcClient initialization (ms) */
export const INIT_TIMEOUT_MS = 30_000;