fix: repair stale terminal workbench conversations
This commit is contained in:
@@ -454,6 +454,135 @@ test("workbench workspace status clears completed AgentRun active trace on read"
|
||||
}
|
||||
});
|
||||
|
||||
test("workbench workspace status repairs terminal selected conversation after active trace was cleared", 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_repair/events") {
|
||||
return send({ items: [] });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result") {
|
||||
return send({
|
||||
runId: "run_workspace_repair",
|
||||
commandId: "cmd_workspace_repair",
|
||||
status: "completed",
|
||||
runStatus: "completed",
|
||||
commandState: "completed",
|
||||
terminalStatus: "completed",
|
||||
completed: true,
|
||||
reply: "repair completed",
|
||||
lastSeq: 2,
|
||||
sessionRef: { sessionId: "ses_issue664_repair", conversationId: "cnv_issue664_repair", threadId: "thread-issue-664-repair" }
|
||||
});
|
||||
}
|
||||
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_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-repair", password: "alice-pass" }, adminLogin.cookie);
|
||||
assert.equal(alice.status, 201);
|
||||
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-repair", 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_issue664_repair", {
|
||||
projectId: "prj_device_pod_workbench",
|
||||
sessionId: "ses_issue664_repair",
|
||||
threadId: "thread-issue-664-repair",
|
||||
sessionStatus: "running",
|
||||
lastTraceId: "trc_issue664_repair_done",
|
||||
messages: []
|
||||
}, aliceLogin.cookie);
|
||||
assert.equal(conversation.status, 200);
|
||||
|
||||
agentSessions.set("trc_issue664_repair_done", {
|
||||
id: "ses_issue664_repair",
|
||||
ownerUserId: alice.body.user.id,
|
||||
conversationId: "cnv_issue664_repair",
|
||||
threadId: "thread-issue-664-repair",
|
||||
status: "active",
|
||||
session: {
|
||||
messageId: "msg_workspace_repair_done",
|
||||
agentRun: {
|
||||
runId: "run_workspace_repair",
|
||||
commandId: "cmd_workspace_repair",
|
||||
backendProfile: "deepseek",
|
||||
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
||||
lastSeq: 0,
|
||||
sessionId: "ses_issue664_repair",
|
||||
conversationId: "cnv_issue664_repair",
|
||||
threadId: "thread-issue-664-repair"
|
||||
}
|
||||
},
|
||||
updatedAt: "2026-06-01T00:00:00.000Z"
|
||||
});
|
||||
|
||||
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
||||
expectedRevision: 1,
|
||||
selectedConversationId: "cnv_issue664_repair",
|
||||
selectedAgentSessionId: "ses_issue664_repair",
|
||||
activeTraceId: null,
|
||||
providerProfile: "deepseek",
|
||||
sessionStatus: "idle",
|
||||
lastTraceId: "trc_issue664_repair_done",
|
||||
updatedByClient: "test-suite"
|
||||
}, aliceLogin.cookie);
|
||||
assert.equal(update.status, 200);
|
||||
assert.equal(update.body.workspace.activeTraceId, null);
|
||||
assert.equal(update.body.workspace.selectedConversation.status, "running");
|
||||
assert.equal(update.body.workspace.selectedConversation.messages.length, 0);
|
||||
|
||||
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
||||
assert.equal(restored.status, 200);
|
||||
assert.equal(restored.body.workspace.activeTraceId, null);
|
||||
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done");
|
||||
assert.equal(restored.body.workspace.workspace.sessionStatus, "idle");
|
||||
assert.equal(restored.body.workspace.selectedConversation.status, "idle");
|
||||
assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_repair_done");
|
||||
assert.equal(restored.body.workspace.selectedConversation.messages.length, 1);
|
||||
assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "idle");
|
||||
assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "repair completed");
|
||||
assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result"));
|
||||
} 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 = {
|
||||
|
||||
@@ -847,7 +847,11 @@ class AccessController {
|
||||
async syncTerminalWorkbenchWorkspace(workspace, actor) {
|
||||
const activeTraceId = safeTraceIdLocal(workspace?.activeTraceId);
|
||||
const codeAgentEnv = this.codeAgentEnv ?? this.env;
|
||||
if (!workspace || !activeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace;
|
||||
if (!workspace || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace;
|
||||
const workspaceJson = normalizeObject(workspace.workspace);
|
||||
const repairTraceId = activeTraceId ? "" : await this.terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson);
|
||||
const traceId = activeTraceId || repairTraceId;
|
||||
if (!traceId) return workspace;
|
||||
const options = {
|
||||
env: codeAgentEnv,
|
||||
fetchImpl: this.fetchImpl,
|
||||
@@ -857,25 +861,24 @@ class AccessController {
|
||||
};
|
||||
|
||||
try {
|
||||
const cached = this.codeAgentChatResults?.get?.(activeTraceId) ?? null;
|
||||
const persisted = cached ? null : await loadPersistedAgentRunResult(activeTraceId, options);
|
||||
const cached = this.codeAgentChatResults?.get?.(traceId) ?? null;
|
||||
const persisted = cached ? null : await loadPersistedAgentRunResult(traceId, options);
|
||||
const current = cached ?? persisted ?? null;
|
||||
if (!current || !canActorReadWorkspaceTrace(current, actor)) return workspace;
|
||||
const synced = current?.agentRun?.runId && current.status === "running"
|
||||
? await syncAgentRunChatResult({ traceId: activeTraceId, currentResult: current, options, traceStore: options.traceStore })
|
||||
? await syncAgentRunChatResult({ traceId, currentResult: current, options, traceStore: options.traceStore })
|
||||
: { result: current };
|
||||
const result = synced.result ?? current;
|
||||
if (!isTerminalCodeAgentResult(result)) return workspace;
|
||||
|
||||
const now = this.now();
|
||||
const workspaceJson = normalizeObject(workspace.workspace);
|
||||
const selectedConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId;
|
||||
const selectedAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId;
|
||||
await this.syncTerminalWorkbenchConversation({
|
||||
workspace,
|
||||
actor,
|
||||
result,
|
||||
activeTraceId,
|
||||
activeTraceId: traceId,
|
||||
selectedConversationId,
|
||||
selectedAgentSessionId,
|
||||
now
|
||||
@@ -895,7 +898,7 @@ class AccessController {
|
||||
selectedAgentSessionId,
|
||||
activeTraceId: null,
|
||||
sessionStatus: terminalWorkbenchSessionStatus(result),
|
||||
lastTraceId: activeTraceId,
|
||||
lastTraceId: traceId,
|
||||
syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null,
|
||||
updatedAt: now,
|
||||
source: "workbench-status-sync",
|
||||
@@ -912,6 +915,17 @@ class AccessController {
|
||||
}
|
||||
}
|
||||
|
||||
async terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson) {
|
||||
try {
|
||||
const traceId = safeTraceIdLocal(workspaceJson?.lastTraceId);
|
||||
if (!traceId || !safeConversationIdLocal(workspace?.selectedConversationId)) return "";
|
||||
const conversation = await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId);
|
||||
return conversationNeedsTerminalRepair(conversation, traceId) ? traceId : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async publicWorkbenchWorkspace(workspace, actor) {
|
||||
const conversation = workspace?.selectedConversationId
|
||||
? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId)
|
||||
@@ -1985,6 +1999,16 @@ function canActorReadWorkspaceTrace(result = {}, actor = null) {
|
||||
if (!ownerUserId || actor?.role === "admin") return true;
|
||||
return ownerUserId === actor?.id;
|
||||
}
|
||||
function conversationNeedsTerminalRepair(conversation, traceId) {
|
||||
if (!conversation || !safeTraceIdLocal(traceId)) return false;
|
||||
const status = textOr(conversation.status, "").toLowerCase();
|
||||
if (["running", "busy", "pending", "active"].includes(status)) return true;
|
||||
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
|
||||
const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null;
|
||||
if (!traceMessage) return true;
|
||||
const messageStatus = textOr(traceMessage.status, "").toLowerCase();
|
||||
return ["", "running", "busy", "pending", "active"].includes(messageStatus);
|
||||
}
|
||||
function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) {
|
||||
const messages = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : [];
|
||||
const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);
|
||||
|
||||
Reference in New Issue
Block a user