Files
hapi/cli/src/daemon/controlServer.ts
T
weishu c35a5ce827 feat: add git worktree session support with improved UI
Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.

Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging

UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity

Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
2025-12-28 13:17:58 +08:00

214 lines
6.1 KiB
TypeScript

/**
* HTTP control server for daemon management
* Provides endpoints for listing sessions, stopping sessions, and daemon shutdown
*/
import fastify, { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify-type-provider-zod';
import { logger } from '@/ui/logger';
import { Metadata } from '@/api/types';
import { TrackedSession } from './types';
import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers';
export function startDaemonControlServer({
getChildren,
stopSession,
spawnSession,
requestShutdown,
onHappySessionWebhook
}: {
getChildren: () => TrackedSession[];
stopSession: (sessionId: string) => boolean;
spawnSession: (options: SpawnSessionOptions) => Promise<SpawnSessionResult>;
requestShutdown: () => void;
onHappySessionWebhook: (sessionId: string, metadata: Metadata) => void;
}): Promise<{ port: number; stop: () => Promise<void> }> {
return new Promise((resolve) => {
const app = fastify({
logger: false // We use our own logger
});
// Set up Zod type provider
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
const typed = app.withTypeProvider<ZodTypeProvider>();
// Session reports itself after creation
typed.post('/session-started', {
schema: {
body: z.object({
sessionId: z.string(),
metadata: z.any() // Metadata type from API
}),
response: {
200: z.object({
status: z.literal('ok')
})
}
}
}, async (request) => {
const { sessionId, metadata } = request.body;
logger.debug(`[CONTROL SERVER] Session started: ${sessionId}`);
onHappySessionWebhook(sessionId, metadata);
return { status: 'ok' as const };
});
// List all tracked sessions
typed.post('/list', {
schema: {
response: {
200: z.object({
children: z.array(z.object({
startedBy: z.string(),
happySessionId: z.string(),
pid: z.number()
}))
})
}
}
}, async () => {
const children = getChildren();
logger.debug(`[CONTROL SERVER] Listing ${children.length} sessions`);
return {
children: children
.filter(child => child.happySessionId !== undefined)
.map(child => ({
startedBy: child.startedBy,
happySessionId: child.happySessionId!,
pid: child.pid
}))
}
});
// Stop specific session
typed.post('/stop-session', {
schema: {
body: z.object({
sessionId: z.string()
}),
response: {
200: z.object({
success: z.boolean()
})
}
}
}, async (request) => {
const { sessionId } = request.body;
logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`);
const success = stopSession(sessionId);
return { success };
});
// Spawn new session
typed.post('/spawn-session', {
schema: {
body: z.object({
directory: z.string(),
sessionId: z.string().optional(),
sessionType: z.enum(['simple', 'worktree']).optional(),
worktreeName: z.string().optional()
}),
response: {
200: z.object({
success: z.boolean(),
sessionId: z.string().optional(),
approvedNewDirectoryCreation: z.boolean().optional()
}),
409: z.object({
success: z.boolean(),
requiresUserApproval: z.boolean().optional(),
actionRequired: z.string().optional(),
directory: z.string().optional()
}),
500: z.object({
success: z.boolean(),
error: z.string().optional()
})
}
}
}, async (request, reply) => {
const { directory, sessionId, sessionType, worktreeName } = request.body;
logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || 'new'}`);
const result = await spawnSession({ directory, sessionId, sessionType, worktreeName });
switch (result.type) {
case 'success':
// Check if sessionId exists, if not return error
if (!result.sessionId) {
reply.code(500);
return {
success: false,
error: 'Failed to spawn session: no session ID returned'
};
}
return {
success: true,
sessionId: result.sessionId,
approvedNewDirectoryCreation: true
};
case 'requestToApproveDirectoryCreation':
reply.code(409); // Conflict - user input needed
return {
success: false,
requiresUserApproval: true,
actionRequired: 'CREATE_DIRECTORY',
directory: result.directory
};
case 'error':
reply.code(500);
return {
success: false,
error: result.errorMessage
};
}
});
// Stop daemon
typed.post('/stop', {
schema: {
response: {
200: z.object({
status: z.string()
})
}
}
}, async () => {
logger.debug('[CONTROL SERVER] Stop daemon request received');
// Give time for response to arrive
setTimeout(() => {
logger.debug('[CONTROL SERVER] Triggering daemon shutdown');
requestShutdown();
}, 50);
return { status: 'stopping' };
});
app.listen({ port: 0, host: '127.0.0.1' }, (err, address) => {
if (err) {
logger.debug('[CONTROL SERVER] Failed to start:', err);
throw err;
}
const port = parseInt(address.split(':').pop()!);
logger.debug(`[CONTROL SERVER] Started on port ${port}`);
resolve({
port,
stop: async () => {
logger.debug('[CONTROL SERVER] Stopping server');
await app.close();
logger.debug('[CONTROL SERVER] Server stopped');
}
});
});
});
}