Merge pull request #1426 from pikasTech/codex/1413-route-workspace-authority
fix: 持久化 Workbench 深链会话选择
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user