fix: avoid unbounded ticket board queries

This commit is contained in:
2026-08-18 00:46:31 +09:00
parent 0a2b24bf5e
commit 200c24bc00
6 changed files with 74 additions and 272 deletions
@@ -218,23 +218,22 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets and Objectives should each be a single sidebar link",
);
assert(
!ticketsLoad.includes("?limit=1000") &&
ticketsLoad.includes('workspaceApiPath(workspaceId, "/tickets/query")') &&
ticketsLoad.includes("ticketLaneDefinitions()") &&
ticketsLoad.includes("ticketLaneQuery(lane)") &&
ticketsLoad.includes("?limit=1000") &&
!ticketsLoad.includes("/tickets/query") &&
ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes('class="ticket-lane-cards"') &&
ticketsPage.includes("lane.tickets.slice(0, lane.visibleCount)") &&
ticketsPage.includes("handleLaneScroll") &&
ticketsPage.includes("Loading 30 more"),
"Tickets list should query and incrementally scroll each Kanban lane",
ticketsPage.includes("revealNextTickets"),
"Tickets list should fetch lightweight summaries once and incrementally reveal each Kanban lane",
);
assert(
ticketPanelModel.includes('label: "Ready + Planning"') &&
ticketPanelModel.includes('label: "In progress + Queued"') &&
ticketPanelModel.includes('label: "Done + Closed"') &&
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
ticketPanelModel.includes('sort: "updated_desc"'),
"Ticket Kanban should combine related states into independent cursor pages",
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
"Ticket Kanban should combine related states into independent 30-item display windows",
);
assert(
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
@@ -189,15 +189,6 @@
font-size: 0.7rem;
text-align: center;
}
.ticket-lane-load-error {
display: grid;
gap: 0.5rem;
color: #d66;
padding: 0.5rem;
}
.ticket-lane-load-error button {
justify-self: start;
}
.ticket-card {
display: grid;
gap: 0.55rem;
@@ -1,8 +1,6 @@
import {
appendUniqueTicketSummaries,
nextTicketLaneVisibleCount,
TICKET_LANE_PAGE_SIZE,
ticketLaneDefinitions,
ticketLaneQuery,
ticketLanes,
ticketWorkerLaunchHref,
ticketWorkerMessage,
@@ -83,47 +81,13 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
]);
});
Deno.test("ticket lane queries request independent pages of 30", () => {
const [readyPlanning, inprogressQueued, doneClosed] = ticketLaneDefinitions();
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
assertEquals(ticketLaneQuery(readyPlanning).states, ["ready", "planning"]);
assertEquals(ticketLaneQuery(inprogressQueued).states, [
"inprogress",
"queued",
]);
assertEquals(ticketLaneQuery(doneClosed, "next-page"), {
attention: [],
cursor: "next-page",
event_kinds: [],
evidence: [],
limit: 30,
linked_objective_id: null,
query: null,
related_ticket_id: null,
relation_kind: null,
review_status: null,
sort: "updated_desc",
states: ["done", "closed"],
updated_after: null,
updated_before: null,
});
});
Deno.test("incremental Ticket pages preserve order and discard duplicate ids", () => {
const current = [
{ id: "first", title: "First", state: "ready", priority: "1" },
{ id: "second", title: "Second", state: "planning", priority: "2" },
] as TicketSummary[];
const incoming = [
{ id: "second", title: "Duplicate", state: "planning", priority: "2" },
{ id: "third", title: "Third", state: "planning", priority: "3" },
] as TicketSummary[];
assertEquals(
appendUniqueTicketSummaries(current, incoming).map((ticket) => ticket.id),
["first", "second", "third"],
);
assertEquals(nextTicketLaneVisibleCount(0, 95), 30);
assertEquals(nextTicketLaneVisibleCount(30, 95), 60);
assertEquals(nextTicketLaneVisibleCount(60, 95), 90);
assertEquals(nextTicketLaneVisibleCount(90, 95), 95);
assertEquals(nextTicketLaneVisibleCount(95, 95), 95);
});
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
@@ -167,15 +131,12 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/tickets/query")',
);
assertIncludes(panelSource, "lane.tickets.slice(0, lane.visibleCount)");
assertIncludes(
panelSource,
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
);
assertIncludes(panelSource, "Loading 30 more…");
assertIncludes(panelSource, "Scroll for");
assertIncludes(detailSource, "{#if orchestratorOnline}");
assertIncludes(detailSource, "!orchestratorOnline");
assertIncludes(detailSource, "Orchestrator offline");
@@ -1,9 +1,4 @@
import type {
TicketDetail,
TicketQueryItem,
TicketQueryRequest,
TicketSummary,
} from "$lib/generated/ticket-api";
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
export const TICKET_STATES = [
"planning",
@@ -77,57 +72,11 @@ export type TicketLane = {
tickets: TicketCardSummary[];
};
export function ticketLaneDefinitions(): readonly TicketLaneDefinition[] {
return LANE_DEFINITIONS;
}
export function ticketLaneQuery(
lane: { states: readonly TicketState[] },
cursor: string | null = null,
): TicketQueryRequest {
return {
attention: [],
cursor,
event_kinds: [],
evidence: [],
limit: TICKET_LANE_PAGE_SIZE,
linked_objective_id: null,
query: null,
related_ticket_id: null,
relation_kind: null,
review_status: null,
sort: "updated_desc",
states: [...lane.states],
updated_after: null,
updated_before: null,
};
}
export function ticketSummaryFromQueryItem(
item: TicketQueryItem,
): TicketCardSummary {
return {
id: item.id,
priority: item.priority === null ? "normal" : String(item.priority),
state: item.state,
title: item.title,
updated_at: item.updated_at,
};
}
export function appendUniqueTicketSummaries(
current: TicketCardSummary[],
incoming: TicketCardSummary[],
): TicketCardSummary[] {
const ids = new Set(current.map((ticket) => ticket.id));
return [
...current,
...incoming.filter((ticket) => {
if (ids.has(ticket.id)) return false;
ids.add(ticket.id);
return true;
}),
];
export function nextTicketLaneVisibleCount(
current: number,
total: number,
): number {
return Math.min(total, current + TICKET_LANE_PAGE_SIZE);
}
function updatedAt(ticket: TicketCardSummary): number {
@@ -1,40 +1,34 @@
<script lang="ts">
import { untrack } from "svelte";
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
appendUniqueTicketSummaries,
ticketLaneQuery,
ticketSummaryFromQueryItem,
type TicketCardSummary,
nextTicketLaneVisibleCount,
TICKET_LANE_PAGE_SIZE,
ticketLanes,
type TicketLane,
type TicketLaneId,
type TicketState,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import "$lib/workspace/styles/tickets.css";
type LanePage = {
id: TicketLaneId;
label: string;
states: TicketState[];
tickets: TicketCardSummary[];
nextCursor: string | null;
hasMore: boolean;
error: string | null;
};
type VisibleTicketLane = TicketLane & { visibleCount: number };
const { data } = $props<{
data: {
workspaceId: string;
ticketLanePages: LanePage[];
tickets: ApiResult<TicketListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
let lanes = $state<(LanePage & { loading: boolean })[]>(
let lanes = $state<VisibleTicketLane[]>(
untrack(() =>
data.ticketLanePages.map((lane: LanePage) => ({ ...lane, loading: false }))
ticketLanes(data.tickets.data?.items ?? []).map((lane) => ({
...lane,
visibleCount: Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length),
}))
),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
@@ -42,7 +36,7 @@
);
let orchestratorStarting = $state(false);
const displayedTicketCount = $derived(
lanes.reduce((count, lane) => count + lane.tickets.length, 0),
lanes.reduce((count, lane) => count + lane.visibleCount, 0),
);
const LANE_LOAD_THRESHOLD_PX = 96;
@@ -63,64 +57,18 @@
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
function updateLane(
laneId: TicketLaneId,
update: (lane: LanePage & { loading: boolean }) =>
LanePage & { loading: boolean },
): void {
lanes = lanes.map((lane) => lane.id === laneId ? update(lane) : lane);
function revealNextTickets(laneId: TicketLaneId): void {
lanes = lanes.map((lane) =>
lane.id === laneId
? {
...lane,
visibleCount: nextTicketLaneVisibleCount(
lane.visibleCount,
lane.tickets.length,
),
}
async function loadMoreTickets(laneId: TicketLaneId): Promise<void> {
const lane = lanes.find((candidate) => candidate.id === laneId);
if (!lane || lane.loading || (!lane.hasMore && !lane.error)) return;
updateLane(laneId, (current) => ({
...current,
loading: true,
error: null,
}));
let result: ApiResult<TicketQueryResponse>;
try {
result = await loadJson<TicketQueryResponse>(
fetch,
workspaceApiPath(data.workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketLaneQuery(lane, lane.nextCursor)),
},
: lane
);
} catch (error) {
updateLane(laneId, (current) => ({
...current,
loading: false,
error: error instanceof Error
? error.message
: "Unable to load more Tickets.",
}));
return;
}
if (!result.data) {
updateLane(laneId, (current) => ({
...current,
loading: false,
error: result.error ?? "Unable to load more Tickets.",
}));
return;
}
const incoming = result.data.items.map(ticketSummaryFromQueryItem);
updateLane(laneId, (current) => ({
...current,
tickets: appendUniqueTicketSummaries(current.tickets, incoming),
nextCursor: result.data?.page.next_cursor ?? null,
hasMore: result.data?.page.has_more ?? false,
loading: false,
error: null,
}));
}
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
@@ -128,7 +76,7 @@
const distanceFromBottom = element.scrollHeight - element.scrollTop -
element.clientHeight;
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
void loadMoreTickets(laneId);
revealNextTickets(laneId);
}
}
</script>
@@ -160,11 +108,15 @@
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{displayedTicketCount}</strong>
<span>tickets loaded</span>
<span>tickets displayed</span>
</div>
</div>
</header>
{#if data.tickets.error}
<p class="workspace-callout is-error">Tickets: {data.tickets.error}</p>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
@@ -177,6 +129,8 @@
<section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)}
{@const displayedTickets = lane.tickets.slice(0, lane.visibleCount)}
{@const hasMore = lane.visibleCount < lane.tickets.length}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
@@ -184,7 +138,7 @@
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">
{lane.tickets.length}{lane.hasMore ? "+" : ""}
{displayedTickets.length}{hasMore ? "+" : ""}
</span>
</header>
@@ -192,7 +146,7 @@
class="ticket-lane-cards"
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each lane.tickets as ticket (ticket.id)}
{#each displayedTickets as ticket (ticket.id)}
<a
class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
@@ -205,24 +159,15 @@
</div>
</a>
{:else}
{#if !lane.error}
<div class="ticket-lane-empty">No tickets</div>
{/if}
{/each}
{#if lane.loading}
<p class="ticket-lane-load-status" aria-live="polite">Loading 30 more…</p>
{:else if lane.error}
<div class="ticket-lane-load-error">
<small>{lane.error}</small>
<button
class="workspace-secondary-button"
type="button"
onclick={() => loadMoreTickets(lane.id)}
>Retry</button>
</div>
{:else if !lane.hasMore && lane.tickets.length > 0}
<p class="ticket-lane-load-status">All tickets loaded.</p>
{#if hasMore}
<p class="ticket-lane-load-status" aria-live="polite">
Scroll for {Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length - lane.visibleCount)} more
</p>
{:else if displayedTickets.length > 0}
<p class="ticket-lane-load-status">All tickets displayed.</p>
{/if}
</div>
</section>
@@ -1,63 +1,20 @@
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
ticketLaneDefinitions,
ticketLaneQuery,
ticketSummaryFromQueryItem,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
export const load = (async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const ticketLanePagesPromise = Promise.all(
ticketLaneDefinitions().map(async (lane) => {
try {
const page = await loadJson<TicketQueryResponse>(
const [tickets, orchestrator] = await Promise.all([
loadJson<TicketListResponse>(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketLaneQuery(lane)),
},
);
return {
id: lane.id,
label: lane.label,
states: [...lane.states],
tickets: page.data?.items.map(ticketSummaryFromQueryItem) ?? [],
nextCursor: page.data?.page.next_cursor ?? null,
hasMore: page.data?.page.has_more ?? false,
error: page.error,
};
} catch (error) {
return {
id: lane.id,
label: lane.label,
states: [...lane.states],
tickets: [],
nextCursor: null,
hasMore: false,
error: error instanceof Error
? error.message
: "Unable to load Tickets.",
};
}
}),
);
const [ticketLanePages, orchestrator] = await Promise.all([
ticketLanePagesPromise,
`${workspaceApiPath(workspaceId, "/tickets")}?limit=1000`,
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
),
]);
return {
workspaceId,
ticketLanePages,
orchestrator,
};
};
return { workspaceId, tickets, orchestrator };
}) satisfies PageLoad;