diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 343527a2..4e4fea69 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -171,6 +171,159 @@ test("workbench workspace is account-scoped, persistent, and gates active agent } }); +test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => { + const agentRunCalls = []; + const agentSessions = new Map(); + const agentRunServer = createServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search }); + const send = (data) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: true, data })}\n`); + }; + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/events") { + return send({ items: [ + { id: "evt_done", runId: "run_workspace_done", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_done", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" } + ] }); + } + if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result") { + return send({ + runId: "run_workspace_done", + commandId: "cmd_workspace_done", + status: "completed", + runStatus: "completed", + commandState: "completed", + terminalStatus: "completed", + completed: true, + reply: "previous turn completed", + lastSeq: 1, + sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared", threadId: "thread-issue-655" } + }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + return send({ id: "run_workspace_next", status: "pending", backendProfile: "deepseek", sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared" } }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/commands") { + return send({ id: "cmd_workspace_next", runId: "run_workspace_next", state: "pending", type: "turn", seq: 1 }); + } + if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/runner-jobs") { + return send({ + action: "create-kubernetes-job", + runId: "run_workspace_next", + commandId: "cmd_workspace_next", + attemptId: "attempt_workspace_next", + runnerId: "runner_workspace_next", + namespace: "agentrun-v01", + jobName: "agentrun-v01-runner-workspace-next" + }); + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + + const agentRunPort = agentRunServer.address().port; + const accessController = createAccessController({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-06-01T00:00:00.000Z" + }); + accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" + }, + accessController, + now: () => "2026-06-01T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-terminal", password: "alice-pass" }, adminLogin.cookie); + assert.equal(alice.status, 201); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-terminal", password: "alice-pass" }); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(workspace.status, 200); + const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_issue655_shared", + threadId: "thread-issue-655", + sessionStatus: "active", + lastTraceId: "trc_issue655_previous", + messages: [{ role: "agent", text: "previous completed turn", traceId: "trc_issue655_previous" }] + }, aliceLogin.cookie); + assert.equal(conversation.status, 200); + + const previousPayload = { + messageId: "msg_workspace_done", + agentRun: { + runId: "run_workspace_done", + commandId: "cmd_workspace_done", + backendProfile: "deepseek", + managerUrl: `http://127.0.0.1:${agentRunPort}`, + lastSeq: 0, + sessionId: "ses_issue655_shared", + conversationId: "cnv_issue655_shared", + threadId: "thread-issue-655" + } + }; + agentSessions.set("trc_issue655_active_done", { + id: "ses_issue655_shared", + ownerUserId: alice.body.user.id, + conversationId: "cnv_issue655_shared", + threadId: "thread-issue-655", + status: "active", + session: previousPayload, + updatedAt: "2026-06-01T00:00:00.000Z" + }); + + const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, { + expectedRevision: 1, + selectedConversationId: "cnv_issue655_shared", + selectedAgentSessionId: "ses_issue655_shared", + activeTraceId: "trc_issue655_active_done", + providerProfile: "deepseek", + sessionStatus: "running", + updatedByClient: "test-suite" + }, aliceLogin.cookie); + assert.equal(update.status, 200); + assert.equal(update.body.workspace.activeTraceId, "trc_issue655_active_done"); + + const next = await postJson(port, "/v1/agent/chat", { + message: "new turn after completed active trace", + traceId: "trc_issue655_after_done", + conversationId: "cnv_issue655_shared", + sessionId: "ses_issue655_shared", + workspaceId: workspace.body.workspace.workspaceId, + expectedWorkspaceRevision: 2, + shortConnection: true + }, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue655_after_done" }); + assert.equal(next.status, 202); + assert.equal(next.body.status, "running"); + assert.equal(next.body.traceId, "trc_issue655_after_done"); + assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result")); + + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(restored.body.workspace.activeTraceId, "trc_issue655_after_done"); + assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api access control grants visible device pods and requires device-pod executor", async () => { let directGatewayDispatches = 0; const gatewayRegistry = { diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 0844326d..fb92aba2 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -850,7 +850,7 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, return { blocked: true }; } const activeTraceId = safeTraceId(workspace.activeTraceId); - const activeResult = activeTraceId ? options.codeAgentChatResults?.get(activeTraceId) ?? null : null; + const activeResult = activeTraceId ? await resolveWorkbenchActiveTraceResult(activeTraceId, options) : null; if (activeTraceId && (!activeResult || activeResult.status === "running")) { sendJson(response, 409, { ok: false, @@ -890,6 +890,29 @@ async function claimWorkbenchWorkspaceTurn({ params = {}, options = {}, traceId, return { workspace: updated ?? workspace }; } +async function resolveWorkbenchActiveTraceResult(traceId, options = {}) { + if (!safeTraceId(traceId)) return null; + const cached = options.codeAgentChatResults?.get?.(traceId) ?? null; + if (cached?.agentRun?.runId && cached.status === "running") { + try { + const synced = await syncAgentRunChatResult({ traceId, currentResult: cached, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); + return synced.result ?? cached; + } catch { + return cached; + } + } + if (cached) return cached; + if (!codeAgentAgentRunAdapterEnabled(options.env ?? process.env)) return null; + try { + const persisted = await loadPersistedAgentRunResult(traceId, options); + if (!persisted?.agentRun?.runId) return persisted; + const synced = await syncAgentRunChatResult({ traceId, currentResult: persisted, options, traceStore: options.traceStore ?? defaultCodeAgentTraceStore }); + return synced.result ?? persisted; + } catch { + return null; + } +} + function safeWorkspaceId(value) { const text = textValue(value); return /^wsp_[A-Za-z0-9_.:-]+$/u.test(text) ? text : "";