Implement explicit session lifecycle and task-scoped worktrees
This commit is contained in:
143
src/ui/server.ts
143
src/ui/server.ts
@@ -23,6 +23,14 @@ type StartRunRequest = {
|
||||
provider?: RunProvider;
|
||||
};
|
||||
|
||||
type CreateSessionRequest = {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
type CloseSessionRequest = {
|
||||
mergeToProject?: boolean;
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
const parsed = Number(value ?? "4317");
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
@@ -102,6 +110,13 @@ function ensureProvider(value: unknown): RunProvider {
|
||||
return value === "claude" ? "claude" : "codex";
|
||||
}
|
||||
|
||||
function ensureNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Field "${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{
|
||||
stateRoot: string;
|
||||
runtimeEventLogPath: string;
|
||||
@@ -299,6 +314,20 @@ async function handleApiRequest(input: {
|
||||
}
|
||||
|
||||
if (pathname === "/api/sessions") {
|
||||
if (method === "POST") {
|
||||
const body = await parseJsonBody<CreateSessionRequest>(request);
|
||||
const projectPath = ensureNonEmptyString(body.projectPath, "projectPath");
|
||||
const session = await runService.createSession({
|
||||
projectPath,
|
||||
});
|
||||
|
||||
sendJson(response, 201, {
|
||||
ok: true,
|
||||
session,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method !== "GET") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
@@ -309,10 +338,12 @@ async function handleApiRequest(input: {
|
||||
stateRoot,
|
||||
runtimeEventLogPath,
|
||||
});
|
||||
const metadata = await runService.listSessions();
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
sessions,
|
||||
sessionMetadata: metadata,
|
||||
runs: runService.listRuns(),
|
||||
});
|
||||
return true;
|
||||
@@ -362,6 +393,118 @@ async function handleApiRequest(input: {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/sessions/") && pathname.endsWith("/run")) {
|
||||
if (method !== "POST") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionId = toRelativePathFromApi(pathname.slice("/api/sessions/".length, -"/run".length));
|
||||
if (!sessionId) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: "Session id is required.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await parseJsonBody<StartRunRequest>(request);
|
||||
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: 'Field "prompt" is required.',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const manifestSource = (() => {
|
||||
if (body.manifest !== undefined) {
|
||||
return body.manifest;
|
||||
}
|
||||
if (typeof body.manifestPath === "string" && body.manifestPath.trim().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const resolvedManifest = manifestSource ?? (() => {
|
||||
if (!body.manifestPath) {
|
||||
return undefined;
|
||||
}
|
||||
return body.manifestPath;
|
||||
})();
|
||||
|
||||
let manifest: unknown;
|
||||
if (typeof resolvedManifest === "string") {
|
||||
manifest = (await manifestStore.read(resolvedManifest)).source;
|
||||
} else if (resolvedManifest !== undefined) {
|
||||
manifest = resolvedManifest;
|
||||
}
|
||||
|
||||
if (!manifest) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: "A manifest or manifestPath is required to start a run.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const record = await runService.startRun({
|
||||
prompt: body.prompt,
|
||||
manifest,
|
||||
manifestPath: body.manifestPath,
|
||||
sessionId,
|
||||
topologyHint: body.topologyHint,
|
||||
initialFlags: ensureBooleanRecord(body.initialFlags),
|
||||
runtimeContextOverrides: ensureRuntimeContext(body.runtimeContextOverrides),
|
||||
simulateValidationNodeIds: ensureStringArray(body.simulateValidationNodeIds),
|
||||
executionMode: ensureExecutionMode(body.executionMode),
|
||||
provider: ensureProvider(body.provider),
|
||||
});
|
||||
|
||||
sendJson(response, 202, {
|
||||
ok: true,
|
||||
run: record,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/sessions/") && pathname.endsWith("/close")) {
|
||||
if (method !== "POST") {
|
||||
methodNotAllowed(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionId = toRelativePathFromApi(pathname.slice("/api/sessions/".length, -"/close".length));
|
||||
if (!sessionId) {
|
||||
sendJson(response, 400, {
|
||||
ok: false,
|
||||
error: "Session id is required.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let body: CloseSessionRequest = {};
|
||||
try {
|
||||
body = await parseJsonBody<CloseSessionRequest>(request);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message !== "Request body is required.") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const session = await runService.closeSession({
|
||||
sessionId,
|
||||
mergeToProject: body.mergeToProject === true,
|
||||
});
|
||||
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
session,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/runs") {
|
||||
if (method === "GET") {
|
||||
sendJson(response, 200, {
|
||||
|
||||
Reference in New Issue
Block a user