Implement explicit session lifecycle and task-scoped worktrees

This commit is contained in:
2026-02-24 10:09:07 -05:00
parent 23ad28ad12
commit ca5fd3f096
21 changed files with 1201 additions and 45 deletions

View File

@@ -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, {