diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index b5a522a9..d38f9b31 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -369,7 +369,7 @@ test("workbench read model exposes session, messages, turn, and trace without wr assert.equal(sessions.body.sessions[0].turnSummary.durationMs, 3000); assert.equal(factQueries[0].projectId, undefined); assert.equal(factQueries[0].limit, 21); - assert.equal(factQueries[0].sessionOrder, "updated_desc"); + assert.equal(factQueries[0].sessionsOrder, "updated_desc"); assert.deepEqual(factQueries[0].families, ["sessions"]); assert.deepEqual(factQueries[1].families, ["messages", "parts", "turns"]); assert.deepEqual(factQueries[1].sessionIds, [session.id]); @@ -1032,7 +1032,7 @@ test("workbench session list overlaps include lookup with the page query", async const runtimeStore = { async queryWorkbenchFacts(params = {}) { queries.push({ ...params, pageReleased }); - if (params.sessionOrder === "updated_desc") await pageGate; + if (params.sessionsOrder === "updated_desc") await pageGate; const filtered = filterFacts(facts, params); return { facts: filtered, @@ -1047,7 +1047,7 @@ test("workbench session list overlaps include lookup with the page query", async try { const { port } = server.address(); const pending = getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(included.id)}&limit=1`); - await waitForCondition(() => queries.some((query) => query.sessionId === included.id) && queries.some((query) => query.sessionOrder === "updated_desc")); + await waitForCondition(() => queries.some((query) => query.sessionId === included.id) && queries.some((query) => query.sessionsOrder === "updated_desc")); const includeIndex = queries.findIndex((query) => query.sessionId === included.id); assert.ok(includeIndex >= 0); assert.equal(queries[includeIndex].pageReleased, false); @@ -1057,7 +1057,7 @@ test("workbench session list overlaps include lookup with the page query", async assert.equal(sessions.status, 200); assert.equal(sessions.body.sessions[0].sessionId, included.id); assert.equal(sessions.body.hasMore, true); - assert.equal(queries.find((query) => query.sessionOrder === "updated_desc")?.sessionProjection, "summary"); + assert.equal(queries.find((query) => query.sessionsOrder === "updated_desc")?.sessionProjection, "summary"); assert.equal(queries.find((query) => query.sessionId === included.id)?.sessionProjection, undefined); } finally { pageReleased = true; @@ -1621,7 +1621,7 @@ function filterFacts(facts, params = {}) { .sort((left, right) => Number(left.projectedSeq) - Number(right.projectedSeq)) : [], checkpoints: families.has("checkpoints") ? facts.checkpoints.filter((record) => matchesFact(record, params, ["traceId", "sessionId", "turnId", "runId", "commandId", "projectionStatus", "projectionHealth"])) : [] }; - if (params.sessionOrder === "updated_desc") { + if (workbenchTestFactOrder(params, "sessions") === "updated_desc") { filtered.sessions.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")) || String(right.sessionId ?? "").localeCompare(String(left.sessionId ?? ""))); } const limit = Number.parseInt(params.limit ?? "", 10); @@ -1629,6 +1629,11 @@ function filterFacts(facts, params = {}) { return Object.fromEntries(Object.entries(filtered).map(([key, value]) => [key, value.slice(0, limit)])); } +function workbenchTestFactOrder(params = {}, family = "") { + const value = params[`${family}Order`] ?? params.order; + return value === "updated_desc" ? "updated_desc" : "updated_asc"; +} + function matchesFact(record, params, fields) { return fields.every((field) => { if (params[field] !== undefined && params[field] !== null && record[field] !== params[field]) return false; diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index ac7ba0d0..dc226ee1 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -322,7 +322,7 @@ async function handleWorkbenchSessionList(response, url, options, actor) { const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId")); const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId")); const readModel = createWorkbenchReadModel(options, actor); - const pageQuery = { ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, sessionOrder: "updated_desc", sessionProjection: "summary" }; + const pageQuery = { ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, sessionsOrder: "updated_desc", sessionProjection: "summary" }; const includeQueryPromise = includeRouteId ? queryWorkbenchSessionInclude(readModel, includeRouteId, perf) : null; const pageResult = await (perf ? perf.measure("workbench_session_page_query", () => readModel.queryFacts(pageQuery)) : readModel.queryFacts(pageQuery)); if (pageResult.error) return sendJson(response, 503, workbenchProjectionStoreError(pageResult.error)); diff --git a/internal/db/runtime-store.test.ts b/internal/db/runtime-store.test.ts index 8889c295..e543c253 100644 --- a/internal/db/runtime-store.test.ts +++ b/internal/db/runtime-store.test.ts @@ -919,6 +919,35 @@ test("configured postgres runtime can query thin Workbench session summaries", a assert.ok(readCall.sql.includes("providerProfile")); }); +test("configured postgres runtime orders Workbench sessions by sessionsOrder updated_desc", async () => { + const queryClient = createFakePostgresClient({ migrationReady: true }); + const store = createConfiguredCloudRuntimeStore({ + env: { + HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres", + HWLAB_CLOUD_RUNTIME_DURABLE: "true" + }, + dbUrl: "postgres://hwlab_redacted@db.example.invalid:5432/hwlab", + queryClient, + now: () => "2026-06-20T10:00:00.000Z" + }); + await store.writeWorkbenchFacts({ + facts: { + sessions: [ + { sessionId: "ses_order_old", ownerUserId: "usr_order", status: "running", updatedAt: "2026-06-15T10:00:00.000Z" }, + { sessionId: "ses_order_new", ownerUserId: "usr_order", status: "running", updatedAt: "2026-06-20T10:00:00.000Z" }, + { sessionId: "ses_order_middle", ownerUserId: "usr_order", status: "running", updatedAt: "2026-06-18T10:00:00.000Z" } + ] + } + }); + queryClient.calls.length = 0; + + const loaded = await store.queryWorkbenchFacts({ families: ["sessions"], sessionsOrder: "updated_desc", limit: 10 }); + const readCall = queryClient.calls.find((call) => call.sql.startsWith("SELECT session_json FROM workbench_sessions")); + + assert.deepEqual(loaded.facts.sessions.map((session) => session.sessionId), ["ses_order_new", "ses_order_middle", "ses_order_old"]); + assert.match(readCall?.sql ?? "", /ORDER BY updated_at DESC/u); +}); + test("configured postgres runtime queries requested workbench fact families concurrently", async () => { const expectedReads = 4; let started = 0; @@ -1357,9 +1386,10 @@ function workbenchFactRows(map, jsonColumn, sql, params, readErrorCode) { error.code = readErrorCode; throw error; } + const direction = sql.includes("ORDER BY updated_at DESC") ? -1 : 1; const rows = [...map.values()] .filter((record) => sqlRecordMatches(record, sql, params)) - .sort((left, right) => String(left.updated_at).localeCompare(String(right.updated_at)) || String(left.id ?? left.session_id ?? left.message_id ?? left.part_id ?? left.turn_id ?? left.trace_id).localeCompare(String(right.id ?? right.session_id ?? right.message_id ?? right.part_id ?? right.turn_id ?? right.trace_id))) + .sort((left, right) => direction * (String(left.updated_at).localeCompare(String(right.updated_at)) || String(left.id ?? left.session_id ?? left.message_id ?? left.part_id ?? left.turn_id ?? left.trace_id).localeCompare(String(right.id ?? right.session_id ?? right.message_id ?? right.part_id ?? right.turn_id ?? right.trace_id)))) .map((record) => ({ [jsonColumn]: record[jsonColumn] })); const limit = sql.includes(" LIMIT $") ? Number(params.at(-1)) : null; return { rows: Number.isInteger(limit) && limit > 0 ? rows.slice(0, limit) : rows };