Merge pull request #1426 from pikasTech/codex/1413-route-workspace-authority

fix: 持久化 Workbench 深链会话选择
This commit is contained in:
Lyon
2026-06-17 20:56:34 +08:00
committed by GitHub
5 changed files with 122 additions and 11 deletions
+80 -1
View File
@@ -100,7 +100,11 @@ test("cloud api /auth/oidc/login stores only same-origin relative returnTo", asy
now: () => "2026-06-03T00:00:00.000Z"
});
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
},
accessController,
now: () => "2026-06-03T00:00:00.000Z"
});
@@ -754,6 +758,81 @@ test("workbench workspace read preserves a newer selected conversation without t
}
});
test("workbench selected conversation projection follows conversation project authority (#1413)", async () => {
const server = createCloudApiServer({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
},
now: () => "2026-06-17T12:40:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
try {
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue1413", password: "alice-pass" }, adminLogin.cookie);
assert.equal(alice.status, 201);
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue1413", password: "alice-pass" });
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(workspace.status, 200);
const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_old", {
projectId: "prj_hwpod_workbench",
sessionId: "ses_issue1413_old",
threadId: "thread-issue-1413-old",
sessionStatus: "running",
lastTraceId: "trc_issue1413_old_running",
messages: [{ role: "agent", text: "old turn running", status: "running", traceId: "trc_issue1413_old_running" }]
}, aliceLogin.cookie);
assert.equal(oldConversation.status, 200);
const targetConversation = await putJson(port, "/v1/agent/conversations/cnv_issue1413_target", {
projectId: "prj_v02_code_agent",
sessionId: "ses_issue1413_target",
threadId: "thread-issue-1413-target",
sessionStatus: "thread-resume-failed",
lastTraceId: "trc_issue1413_target_failed",
messages: [{ role: "agent", text: "target thread resume failed", status: "failed", traceId: "trc_issue1413_target_failed" }]
}, aliceLogin.cookie);
assert.equal(targetConversation.status, 200);
const oldActive = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
selectedConversationId: "cnv_issue1413_old",
selectedAgentSessionId: "ses_issue1413_old",
activeTraceId: "trc_issue1413_old_running",
lastTraceId: "trc_issue1413_old_running",
sessionStatus: "running",
updatedByClient: "test-suite"
}, aliceLogin.cookie);
assert.equal(oldActive.status, 200);
const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
projectId: "prj_hwpod_workbench",
conversationId: "cnv_issue1413_target",
sessionId: "ses_issue1413_target",
updatedByClient: "test-suite"
}, aliceLogin.cookie);
assert.equal(selected.status, 200);
assert.equal(selected.body.workspace.selectedConversationId, "cnv_issue1413_target");
assert.equal(selected.body.workspace.selectedConversation.conversationId, "cnv_issue1413_target");
assert.equal(selected.body.workspace.selectedConversation.projectId, "prj_v02_code_agent");
assert.equal(selected.body.workspace.workspace.selectedConversationId, "cnv_issue1413_target");
assert.equal(selected.body.workspace.workspace.lastTraceId, undefined);
assert.equal(selected.body.workspace.workspace.sessionStatus, "thread-resume-failed");
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie);
assert.equal(restored.status, 200);
assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue1413_target");
assert.equal(restored.body.workspace.selectedConversation.conversationId, "cnv_issue1413_target");
assert.equal(restored.body.workspace.selectedConversation.projectId, "prj_v02_code_agent");
assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue1413_target");
assert.equal(restored.body.workspace.workspace.lastTraceId, undefined);
assert.equal(restored.body.workspace.workspace.sessionStatus, "thread-resume-failed");
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("manual Code Agent session select restores running active trace", async () => {
const accessController = createAccessController({
env: {
+18 -8
View File
@@ -1196,10 +1196,13 @@ class AccessController {
});
}
const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? "");
if (selectedConversationId) {
const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId);
if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403));
}
const selectedConversation = selectedConversationId
? await this.visibleConversationForWorkspaceAuthority(auth.actor, selectedConversationId, body.projectId ?? current.projectId)
: null;
if (selectedConversationId && !selectedConversation) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403));
const workspacePatchBody = selectedConversation
? { ...body, selectedConversationProjectId: selectedConversation.projectId }
: body;
const workspace = await this.store.updateWorkspace?.({
workspaceId,
ownerUserId: auth.actor.id,
@@ -1211,7 +1214,7 @@ class AccessController {
selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null,
providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""),
patch: normalizeWorkspacePatch(body, auth.actor),
patch: normalizeWorkspacePatch(workspacePatchBody, auth.actor),
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
@@ -1229,7 +1232,7 @@ class AccessController {
const body = await jsonBody(request);
const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`;
if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId);
const existing = await this.visibleConversationForWorkspaceAuthority(auth.actor, conversationId, body.projectId ?? current.projectId);
if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`;
if (!existing) {
@@ -1252,7 +1255,7 @@ class AccessController {
selectedConversationId: conversationId,
selectedAgentSessionId: sessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null,
patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, sessionId }, auth.actor),
patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, selectedConversationProjectId: existing?.projectId ?? body.projectId ?? current.projectId, sessionId }, auth.actor),
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
@@ -1318,12 +1321,19 @@ class AccessController {
}
async publicWorkbenchWorkspace(workspace, actor) {
const selectedConversationProjectId = textOr(workspace?.workspace?.selectedConversationProjectId ?? workspace?.workspace?.projectId, workspace?.projectId ?? "");
const conversation = workspace?.selectedConversationId
? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId)
? await this.visibleConversationForWorkspaceAuthority(actor, workspace.selectedConversationId, selectedConversationProjectId)
: null;
return publicWorkbenchWorkspace(workspace, { conversation });
}
async visibleConversationForWorkspaceAuthority(actor, conversationId, projectId = "", options = {}) {
const visible = await this.visibleConversationForActor(actor, conversationId, projectId, options);
if (visible || !textOr(projectId, "")) return visible;
return this.visibleConversationForActor(actor, conversationId, "", options);
}
async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) {
if (!safeConversationIdLocal(conversationId)) return null;
const sessions = await this.store.listAgentSessionsForUser?.({
@@ -144,7 +144,11 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
function createScenarioState(scenarioId: string): ScenarioState {
const base = structuredClone(capture.scenario);
const id = scenarioId || "baseline";
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : id === "stale-submit-restore" ? "cnv_running" : base.selectedConversationId;
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace"
? "cnv_failed"
: id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore"
? "cnv_running"
: base.selectedConversationId;
const staleNestedTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
return {
scenarioId: id,
+5 -1
View File
@@ -230,7 +230,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
setActiveConversationSelection(normalized);
const current = await ensureWorkspace();
if (!current) return false;
if (activeConversationId.value === normalized && messages.value.length > 0) return true;
if (activeConversationId.value === normalized && messages.value.length > 0) {
const conversation = activeConversation.value ?? visibleConversations.value.find((item) => item.conversationId === normalized) ?? null;
if (conversation) await persistSelectedConversation(conversation, conversationProjectId(conversation, activeProjectId.value));
return true;
}
const requestEpoch = beginWorkspaceSelection();
startWorkbenchSessionSwitch({ conversationId: normalized, source: "deeplink", targetState: "unknown", cache: "cold" });
switchingConversationId.value = normalized;
@@ -11,3 +11,17 @@ test("deep link hydrates the same authority path as tab selection", async ({ pag
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed");
await saveScreenshot(page, testInfo, "deep-link-failed");
});
test.describe("deep link stale workspace authority", () => {
test.use({ scenarioId: "deep-link-stale-workspace-authority" });
test("deep link persists route conversation over stale workspace selection", async ({ page }) => {
await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench");
await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true");
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖");
await expect.poll(async () => (await fakeServerState(page)).selectedConversationId).toBe("cnv_failed");
const state = await fakeServerState(page);
const selectRequests = state.selectRequests as Array<{ conversationId?: unknown }>;
expect(selectRequests.some((request) => request.conversationId === "cnv_failed")).toBeTruthy();
});
});