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:
parent
2a0d63accd
commit
9af08f6480
3 changed files with 470 additions and 0 deletions
235
packages/daemon/src/project-scanner.test.ts
Normal file
235
packages/daemon/src/project-scanner.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
99
packages/daemon/src/project-scanner.ts
Normal file
99
packages/daemon/src/project-scanner.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue