feat(v0.1): add mgr session PVC lifecycle for true session state persistence
PR B for #770: mgr/session-pvc.ts + server endpoints + selftest. - 新模块 src/mgr/session-pvc.ts: createSessionPvc / getSessionPvcSummary / deleteSessionPvc / refreshSessionPvcSummary / runSessionStorageGc / startSessionStorageGcLoop - Server 增量 4 个 endpoint: * POST /api/v1/sessions: 创建 session 同步创建 PVC * GET /api/v1/sessions/:id/storage: 查询 PVC 摘要 * DELETE /api/v1/sessions/:id/storage: 删 PVC + storage_kind=evicted * POST /api/v1/sessions/:id/storage/refresh: runner 上报 PVC 摘要 * POST /api/v1/sessions/storage/gc: 手动触发 GC - mgr SA RBAC 已在 PR A 增加;manager server 不直连 Kubernetes API(kubectl 由 mgr 容器内执行) - SessionRecord 增量 storageKind / storagePvcName / storageNamespace / storageSizeBytes / storageFilesCount / storageSha256 / storageUpdatedAt / storagePvcPhase / storageEvictedAt / codexRolloutSubdir - kubernetes-runner-job 短路:run 引用 evicted session 时直接返回 session-store-evicted,不创建 runner Job - KubectlHandler 可注入,selftest 覆盖 create / summary / refresh / eviction / gc / REST 路径 - GC loop 默认 5min(AGENTRUN_SESSION_GC_INTERVAL_MS 可调) runner / backend / HWLAB adapter 在 PR C / PR D 落地。
This commit is contained in:
+87
-2
@@ -10,6 +10,24 @@ import { createKubernetesRunnerJob } from "./kubernetes-runner-job.js";
|
||||
import { dispatchQueueTask, refreshQueueTaskFromCore } from "./queue-dispatch.js";
|
||||
import { buildRunResult } from "./result.js";
|
||||
import { runnerJobStatusSummary } from "./runner-job-status.js";
|
||||
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
|
||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||
import type { SessionPvcOptions } from "./session-pvc.js";
|
||||
|
||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||
}
|
||||
|
||||
function sessionPvcOptionsForRequest(serverDefaults: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string } | undefined, runnerJobDefaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
if (serverDefaults?.kubectlHandler) {
|
||||
const opts: SessionPvcOptions = { kubectlHandler: serverDefaults.kubectlHandler };
|
||||
if (serverDefaults.kubectlCommand) opts.kubectlCommand = serverDefaults.kubectlCommand;
|
||||
if (serverDefaults.storageClassName) opts.storageClassName = serverDefaults.storageClassName;
|
||||
if (serverDefaults.size) opts.size = serverDefaults.size;
|
||||
return opts;
|
||||
}
|
||||
return pvcOptions(runnerJobDefaults);
|
||||
}
|
||||
|
||||
export interface ManagerServerOptions {
|
||||
store?: AgentRunStore;
|
||||
@@ -23,6 +41,7 @@ export interface ManagerServerOptions {
|
||||
serviceAccountName?: string;
|
||||
kubectlCommand?: string;
|
||||
};
|
||||
sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string };
|
||||
}
|
||||
|
||||
export interface StartedManagerServer {
|
||||
@@ -35,12 +54,13 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr
|
||||
const store = options.store ?? await openAgentRunStoreFromEnv();
|
||||
const sourceCommit = options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown";
|
||||
const runnerJobDefaults = options.runnerJobDefaults;
|
||||
const sessionPvcDefaults = options.sessionPvcOptions;
|
||||
const server = createServer(async (req, res) => {
|
||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}) });
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}) });
|
||||
writeJson(res, 200, { ok: true, data, traceId });
|
||||
} catch (error) {
|
||||
const agentError = normalizeError(error);
|
||||
@@ -61,7 +81,7 @@ async function readBody(req: import("node:http").IncomingMessage): Promise<unkno
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]> }): Promise<JsonValue> {
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]> }): Promise<JsonValue> {
|
||||
const path = url.pathname;
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
const database = await store.health();
|
||||
@@ -117,6 +137,65 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
}
|
||||
throw new AgentRunError("schema-invalid", `session control action ${action} is not supported`, { httpStatus: 400 });
|
||||
}
|
||||
const sessionStorageMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/storage$/u);
|
||||
if (method === "GET" && sessionStorageMatch) {
|
||||
const summary = await getSessionPvcSummary({ store, sessionId: sessionStorageMatch[1] ?? "", options: sessionPvcOptionsForRequest(sessionPvcDefaults, runnerJobDefaults) });
|
||||
return summary as unknown as JsonValue;
|
||||
}
|
||||
if (method === "DELETE" && sessionStorageMatch) {
|
||||
return await deleteSessionPvc({ store, sessionId: sessionStorageMatch[1] ?? "", options: sessionPvcOptionsForRequest(sessionPvcDefaults, runnerJobDefaults) }) as unknown as JsonValue;
|
||||
}
|
||||
const sessionStorageRefreshMatch = path.match(/^\/api\/v1\/sessions\/([^/]+)\/storage\/refresh$/u);
|
||||
if (method === "POST" && sessionStorageRefreshMatch) {
|
||||
const record = asRecord(body ?? {}, "sessionStorageRefresh");
|
||||
const summary: SessionPvcSummary = {
|
||||
pvcName: stringField(record, "pvcName"),
|
||||
namespace: stringField(record, "namespace"),
|
||||
pvcPhase: typeof record.pvcPhase === "string" ? record.pvcPhase : null,
|
||||
storageSizeBytes: typeof record.storageSizeBytes === "number" ? record.storageSizeBytes : null,
|
||||
storageFilesCount: typeof record.storageFilesCount === "number" ? record.storageFilesCount : null,
|
||||
storageSha256: typeof record.storageSha256 === "string" ? record.storageSha256 : null,
|
||||
storageUpdatedAt: typeof record.storageUpdatedAt === "string" ? record.storageUpdatedAt : new Date().toISOString(),
|
||||
codexRolloutSubdir: typeof record.codexRolloutSubdir === "string" && record.codexRolloutSubdir.length > 0 ? record.codexRolloutSubdir : "sessions",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
const refreshed = await refreshSessionPvcSummary({ store, sessionId: sessionStorageRefreshMatch[1] ?? "", summary, options: sessionPvcOptionsForRequest(sessionPvcDefaults, runnerJobDefaults) });
|
||||
return { action: "session-storage-refreshed", sessionId: refreshed.sessionId, summary: refreshed } as unknown as JsonValue;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/sessions/storage/gc") {
|
||||
const cycle = await runSessionStorageGc({ store, options: sessionPvcOptionsForRequest(sessionPvcDefaults, runnerJobDefaults) });
|
||||
return cycle as unknown as JsonValue;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/sessions") {
|
||||
const record = asRecord(body ?? {}, "sessionCreate");
|
||||
const sessionId = typeof record.sessionId === "string" && record.sessionId.trim().length > 0 ? record.sessionId.trim() : `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const tenantId = stringField(record, "tenantId");
|
||||
const projectId = stringField(record, "projectId");
|
||||
const backendProfileRaw = typeof record.backendProfile === "string" ? record.backendProfile : "codex";
|
||||
if (backendProfileRaw !== "codex" && backendProfileRaw !== "deepseek" && backendProfileRaw !== "minimax-m3") throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileRaw} is not supported`, { httpStatus: 400 });
|
||||
const conversationId = typeof record.conversationId === "string" ? record.conversationId : null;
|
||||
const codexRolloutSubdir = typeof record.codexRolloutSubdir === "string" && record.codexRolloutSubdir.length > 0 ? record.codexRolloutSubdir : "sessions";
|
||||
const expiresAt = typeof record.expiresAt === "string" ? record.expiresAt : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const existing = await store.getSession(sessionId);
|
||||
if (existing) {
|
||||
if (existing.storageKind === "evicted") throw new AgentRunError("session-store-evicted", `session ${sessionId} storage has been evicted; create a new sessionId`, { httpStatus: 409 });
|
||||
return { action: "session-exists", session: existing, pvcName: existing.storagePvcName ?? null, pvcPhase: existing.storagePvcPhase ?? null, codexRolloutSubdir: existing.codexRolloutSubdir ?? "sessions", valuesPrinted: false } as unknown as JsonValue;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const session = await store.upsertSession({
|
||||
sessionId,
|
||||
tenantId,
|
||||
projectId,
|
||||
backendProfile: backendProfileRaw as never,
|
||||
conversationId,
|
||||
threadId: null,
|
||||
metadata: typeof record.metadata === "object" && record.metadata !== null && !Array.isArray(record.metadata) ? record.metadata as Record<string, JsonValue> : {},
|
||||
expiresAt,
|
||||
codexRolloutSubdir,
|
||||
});
|
||||
const pvc = await createSessionPvc({ store, sessionId, options: { ...sessionPvcOptionsForRequest(sessionPvcDefaults, runnerJobDefaults), defaultCodexRolloutSubdir: codexRolloutSubdir } });
|
||||
return { action: "session-created", session, pvc, valuesPrinted: false } as unknown as JsonValue;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/queue/tasks") return await store.createQueueTask(validateCreateQueueTask(body)) as unknown as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/queue/tasks") {
|
||||
const state = url.searchParams.get("state");
|
||||
@@ -286,6 +365,12 @@ function numberField(record: JsonRecord, key: string, fallback: number): number
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringField(record: JsonRecord, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): AgentRunError {
|
||||
if (error instanceof AgentRunError) return error;
|
||||
return new AgentRunError("infra-failed", error instanceof Error ? error.message : String(error), { httpStatus: 500 });
|
||||
|
||||
Reference in New Issue
Block a user