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:
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { startManagerServer } from "../../mgr/server.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc, sessionPvcNameFor } from "../../mgr/session-pvc.js";
|
||||
import type { KubectlHandler, SessionPvcOptions } from "../../mgr/session-pvc.js";
|
||||
import type { SelfTestCase } from "../harness.js";
|
||||
|
||||
function makeFakeKubectl(): KubectlHandler {
|
||||
return ({ args, stdin }) => {
|
||||
if (args[0] === "create") {
|
||||
const manifest = stdin ? JSON.parse(stdin) : {};
|
||||
return { stdout: JSON.stringify({ apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: manifest.metadata?.name, namespace: manifest.metadata?.namespace }, status: { phase: "Bound" } }), stderr: "", exitCode: 0 };
|
||||
}
|
||||
if (args[0] === "get") {
|
||||
return { stdout: JSON.stringify({ apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: args[2] }, status: { phase: "Bound", capacity: { storage: "1Gi" } } }), stderr: "", exitCode: 0 };
|
||||
}
|
||||
if (args[0] === "delete") {
|
||||
return { stdout: "", stderr: "", exitCode: 0 };
|
||||
}
|
||||
return { stdout: "{}", stderr: "", exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
const selfTest: SelfTestCase = async () => {
|
||||
const fakeKubectl = makeFakeKubectl();
|
||||
const opts: SessionPvcOptions = { kubectlHandler: fakeKubectl, defaultCodexRolloutSubdir: "sessions" };
|
||||
const store = new MemoryAgentRunStore();
|
||||
const sessionId = "sess_test_pvc_001";
|
||||
store.upsertSession({ sessionId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(Date.now() + 86_400_000).toISOString(), codexRolloutSubdir: "sessions" });
|
||||
const summary = await createSessionPvc({ store, sessionId, options: opts });
|
||||
assert.equal(summary.pvcName, sessionPvcNameFor(sessionId));
|
||||
assert.equal(summary.pvcPhase, "Bound");
|
||||
assert.equal(summary.codexRolloutSubdir, "sessions");
|
||||
const after = await store.getSession(sessionId);
|
||||
assert.equal(after?.storageKind, "pvc");
|
||||
assert.equal(after?.storagePvcName, summary.pvcName);
|
||||
|
||||
const readSummary = await getSessionPvcSummary({ store, sessionId, options: opts });
|
||||
assert.equal(readSummary.pvcName, summary.pvcName);
|
||||
assert.equal(readSummary.pvcPhase, "Bound");
|
||||
assert.equal(readSummary.storageSizeBytes, 1024 * 1024 * 1024);
|
||||
|
||||
await refreshSessionPvcSummary({ store, sessionId, summary: { ...summary, storageFilesCount: 7, storageSha256: "abc123" }, options: opts });
|
||||
const refreshed = await store.getSession(sessionId);
|
||||
assert.equal(refreshed?.storageFilesCount, 7);
|
||||
assert.equal(refreshed?.storageSha256, "abc123");
|
||||
|
||||
const evicted = await deleteSessionPvc({ store, sessionId, options: opts });
|
||||
assert.equal(evicted.storageKind, "evicted");
|
||||
const evictedSession = await store.getSession(sessionId);
|
||||
assert.equal(evictedSession?.storageKind, "evicted");
|
||||
assert.ok(evictedSession?.storageEvictedAt);
|
||||
|
||||
const now = Date.now();
|
||||
const expiredId = "sess_expired_001";
|
||||
const activeId = "sess_active_001";
|
||||
store.upsertSession({ sessionId: expiredId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(now - 60_000).toISOString(), codexRolloutSubdir: "sessions" });
|
||||
await store.refreshSessionStorage({ sessionId: expiredId, storageKind: "pvc", pvcName: sessionPvcNameFor(expiredId), codexRolloutSubdir: "sessions" });
|
||||
store.upsertSession({ sessionId: activeId, tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex", conversationId: null, threadId: null, metadata: {}, expiresAt: new Date(now - 60_000).toISOString(), codexRolloutSubdir: "sessions" });
|
||||
await store.refreshSessionStorage({ sessionId: activeId, storageKind: "pvc", pvcName: sessionPvcNameFor(activeId), codexRolloutSubdir: "sessions" });
|
||||
const activeRun = store.createRun({ tenantId: "hwlab", projectId: "pikasTech/HWLAB", workspaceRef: { kind: "opaque", path: "." }, providerId: "G14", backendProfile: "codex", executionPolicy: { sandbox: "worktree", approval: "never", timeoutMs: 600_000, network: "restricted", secretScope: {} }, traceSink: null, sessionRef: { sessionId: activeId }, resourceBundleRef: null });
|
||||
store.createCommand(activeRun.id, { type: "turn", payload: { prompt: "test" } });
|
||||
const cycle = await runSessionStorageGc({ store, options: opts, now, maxSessions: 10 });
|
||||
assert.ok(cycle.scanned >= 1);
|
||||
assert.ok(cycle.deleted >= 1);
|
||||
const expiredAfter = await store.getSession(expiredId);
|
||||
assert.equal(expiredAfter?.storageKind, "evicted");
|
||||
const activeAfter = await store.getSession(activeId);
|
||||
assert.equal(activeAfter?.storageKind, "pvc");
|
||||
|
||||
const restStore = new MemoryAgentRunStore();
|
||||
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: restStore, sessionPvcOptions: { kubectlHandler: fakeKubectl } });
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const create = await client.post("/api/v1/sessions", { sessionId: "sess_rest_create_001", tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex" }) as { action: string; pvc: { pvcName: string; pvcPhase: string } };
|
||||
assert.equal(create.action, "session-created");
|
||||
assert.ok(create.pvc.pvcName.startsWith("agentrun-v01-session-"));
|
||||
const existing = await client.post("/api/v1/sessions", { sessionId: "sess_rest_create_001", tenantId: "hwlab", projectId: "pikasTech/HWLAB", backendProfile: "codex" }) as { action: string };
|
||||
assert.equal(existing.action, "session-exists");
|
||||
} finally {
|
||||
if (server.server.listening) await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
|
||||
return { name: "mgr-session-pvc", tests: ["session-pvc-create-summary-eviction", "session-pvc-gc", "session-pvc-rest-create"] };
|
||||
};
|
||||
|
||||
export default selfTest;
|
||||
Reference in New Issue
Block a user