fix: simplify Ticket reporting and speed up browser lists

Remove model-supplied assignment and retry identities from implementation reports; retain Host idempotency, authentication and notification evidence.

Add a SQL-only browser projection and decouple auxiliary page loads. Preserve authoritative MR evidence queries. Include regression tests and component benchmarks, documenting the remaining no-match search cost.
This commit is contained in:
2026-10-16 01:58:15 +09:00
parent 9f1cbbd385
commit b67aa2de7d
42 changed files with 3197 additions and 152 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -137,6 +137,12 @@ export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProje
export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
export type TicketBrowseQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; current_closure?: TicketClosureJudgment | null; id: string; resource_key: string; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; updated_at?: string | null; };
export type TicketBrowseQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; closure_reason?: TicketClosureReason | null; cursor?: string | null; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; query?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; };
export type TicketBrowseQueryResponse = { items: Array<TicketBrowseQueryItem>; page: QueryPage; record_authority: string; };
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
@@ -169,7 +175,7 @@ export type TicketImplementationResultKind = "handoff" | "result" | "blocker";
export type TicketImplementationResultReceipt = { created_at: string; request: TicketImplementationResultRequest; result_id: string; ticket_id: string; };
export type TicketImplementationResultRequest = { assignment_id: string; body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; orchestrator_assignment_id: string; };
export type TicketImplementationResultRequest = { body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; };
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -2,6 +2,7 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import type { TicketBrowseQueryResponse } from "#lib/generated/ticket-api.ts";
import { loadJson } from "./http.ts";
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
@@ -12,6 +13,7 @@ import {
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
parseTicketBrowseQueryResponse,
parseTicketRecordRef,
parseTicketRoleAssignmentMutationResponse,
parseTicketSummary,
@@ -45,6 +47,97 @@ const page = {
source_truncated: false,
};
function browseFixture(): TicketBrowseQueryResponse {
return {
record_authority: "sqlite",
page: { ...page, has_more: true, next_cursor: "opaque-cursor" },
items: [{
id: "ticket-id", resource_key: "T-1", title: "Minimal browse row",
state: "open", updated_at: null, tags: [], current_closure: null,
assignment_tracking: [],
}],
};
}
Deno.test("Ticket browse accepts the generated minimal row and preserves cursor metadata without enriched query fields", () => {
const fixture = browseFixture();
const parsed = parseTicketBrowseQueryResponse(fixture);
assertEquals(parsed, { page: fixture.page, record_authority: "sqlite", items: fixture.items });
for (const field of ["evidence", "merge_requests", "content_digest", "snippet", "blocker_count", "current_worker"]) {
assertEquals(field in parsed.items[0], false);
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: null }] }), "unknown field");
}
assertEquals(parseTicketBrowseQueryResponse({ ...fixture, items: [], page: { ...page, returned: 0 } }).items, []);
});
Deno.test("Ticket browse preserves case, tags and self-reported handling independently", () => {
const fixture = browseFixture();
const assignment = {
workspace_id: "workspace-a", ticket_id: "ticket-id", assignment_id: "delegation-1",
role: "orchestrator", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
assigned_by: "user:alice", assigned_at: "2026-10-15T00:00:00Z", current: true,
release: null, last_event_id: "event-1", updated_at: null, migration_diagnostic: null,
tracking: { status: "done", waiting_reason: null, progress: "Investigated", next_action: "Human decision", result: "Options returned" },
};
for (const state of ["open", "closed"]) {
const row = parseTicketBrowseQueryResponse({
...fixture,
items: [{ ...fixture.items[0], state, tags, current_closure: state === "closed" ? closure : null, assignment_tracking: [assignment] }],
}).items[0];
assertEquals(row.state, state);
assertEquals(row.tags, tags);
for (const [field, expected] of Object.entries(assignment)) {
assertEquals((row.assignment_tracking[0] as unknown as Record<string, unknown>)[field], expected);
}
assertEquals(row.current_closure, state === "closed" ? closure : null);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], assignment_tracking: [{ ...assignment, tracking: { status: "running" } }] }] }), "status is invalid");
});
Deno.test("Ticket browse rejects missing required fields, legacy states and malformed row values", () => {
const fixture = browseFixture();
for (const field of ["id", "resource_key", "title", "state", "tags", "assignment_tracking"]) {
const row: Record<string, unknown> = { ...fixture.items[0] };
delete row[field];
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [row] }), `items[0].${field}`);
}
for (const [field, value] of [
["id", 1], ["title", false], ["state", "done"], ["state", "queued"],
["updated_at", 7], ["tags", null], ["tags", [{ ...tags[0], id: " " }]],
["assignment_tracking", {}], ["current_closure", { ...closure, reason: "done" }],
] as const) {
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: value }] }), `items[0].${field}`);
}
});
Deno.test("Ticket browse validates envelope, pagination and collection bounds", () => {
const fixture = browseFixture();
for (const field of ["items", "page", "record_authority"]) {
const wire: Record<string, unknown> = { ...fixture };
delete wire[field];
assertThrows(() => parseTicketBrowseQueryResponse(wire), `response.${field}`);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, unexpected: true }), "unknown field");
for (const [field, value] of [["has_more", "yes"], ["next_cursor", 42], ["limit", -1], ["returned", 1.5], ["source_truncated", null]] as const) {
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, [field]: value } }), `page.${field}`);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, extra: true } }), "unknown field");
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: Array(4097).fill(fixture.items[0]) }), "items exceeds its item limit");
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], title: "x".repeat(262145) }] }), "title exceeds its length limit");
});
Deno.test("Ticket browse load diagnostics identify the malformed field without exposing row content", async () => {
const fixture = browseFixture();
const result = await loadJson(
(async () => Response.json({ ...fixture, items: [{ ...fixture.items[0], title: "PRIVATE_TITLE", tags: null }] })) as typeof fetch,
"/api/w/workspace-a/tickets/browse", { method: "POST" },
parseTicketBrowseQueryResponse, TICKET_BROWSER_API_LOAD_POLICY,
);
assertEquals(result.data, null);
assertEquals(result.error, "Ticket Browser API: Ticket browse response.items[0].tags must be an array");
assertEquals(result.error?.includes("PRIVATE_TITLE"), false);
});
Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => {
const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" });
const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } });
@@ -41,6 +41,8 @@ import type {
TicketListItemSummary,
TicketListResponse,
TicketMergeRequestSummary,
TicketBrowseQueryItem,
TicketBrowseQueryResponse,
TicketQueryItem,
TicketQueryResponse,
TicketQueueOutcome,
@@ -1776,6 +1778,37 @@ export function parseMergeRequestDetailResponse(
};
}
export function parseTicketBrowseQueryResponse(value: unknown): TicketBrowseQueryResponse {
const label = "Ticket browse response";
const page = object(value, label);
exact(page, ["items", "page", "record_authority"], label);
return {
page: parseQueryPage(page.page, `${label}.page`),
record_authority: string(page.record_authority, `${label}.record_authority`),
items: array(page.items, `${label}.items`, (value, label): TicketBrowseQueryItem => {
const item = object(value, label);
exact(item, [
"id", "resource_key", "title", "state", "updated_at", "tags",
"current_closure", "assignment_tracking",
], label);
return {
id: string(item.id, `${label}.id`),
resource_key: string(item.resource_key, `${label}.resource_key`),
title: string(item.title, `${label}.title`),
state: parseTicketState(item.state, `${label}.state`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
current_closure: item.current_closure == null
? item.current_closure as null | undefined
: parseClosure(item.current_closure, `${label}.current_closure`),
assignment_tracking: array(
item.assignment_tracking, `${label}.assignment_tracking`, parseAssignmentTrackingProjection,
),
};
}),
};
}
export function parseTicketQueryResponse(value: unknown): TicketQueryResponse {
const label = "Ticket query response";
const page = object(value, label);
@@ -391,7 +391,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
// クエリ・ページングの振舞いはticket-search.testとTicket browser suiteで検証する。
assert(
ticketsLoad.includes("compileTicketSearch") &&
ticketsLoad.includes("/tickets/query") &&
ticketsLoad.includes("/tickets/browse") &&
ticketsPage.includes('class="ticket-list"') &&
!ticketsPage.includes("TicketQueryFilters") &&
!ticketsPage.includes("ticket-kanban"),
@@ -1,7 +1,7 @@
import type {
TicketAssignmentPrincipalSnapshot,
TicketClosureReason,
TicketQueryRequest,
TicketBrowseQueryRequest,
WorkspaceTag,
} from "../../generated/ticket-api.ts";
@@ -114,15 +114,15 @@ function reason(value: string, quoted: boolean): TicketClosureReason {
export function compileTicketSearch(
input: string,
tags: readonly WorkspaceTag[],
): TicketQueryRequest {
const request: TicketQueryRequest = {
): TicketBrowseQueryRequest {
const request: TicketBrowseQueryRequest = {
query: null,
states: [],
tag_ids: [],
assigned_principal: null,
handling_status: null,
sort: "updated_desc",
limit: 30 as TicketQueryRequest["limit"],
limit: 30 as TicketBrowseQueryRequest["limit"],
cursor: null,
};
const text: string[] = [];
@@ -1,8 +1,6 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
@@ -12,7 +10,7 @@ import {
ticketSearchNeedsTags,
} from "#lib/workspace/tickets/ticket-search.ts";
import type {
TicketQueryRequest,
TicketBrowseQueryRequest,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
@@ -22,7 +20,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
const workspaceId = params.workspaceId;
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
const loadTickets = async () => {
let queryRequest: TicketQueryRequest | null = null;
let queryRequest: TicketBrowseQueryRequest | null = null;
try {
const tags: WorkspaceTag[] = [];
// タグ名をstable IDへ解決する場合だけ一覧を取得する。状態検索をタグAPIに依存させない。
@@ -54,13 +52,13 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
queryRequest = compileTicketSearch(search, tags);
const tickets = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
workspaceApiPath(workspaceId, "/tickets/browse"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(queryRequest),
},
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return { queryRequest, tickets };
@@ -74,15 +72,6 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
};
}
};
const [result, orchestrator, repositories] = await Promise.all([
loadTickets(),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return { workspaceId, search, ...result, orchestrator, repositories };
const result = await loadTickets();
return { workspaceId, search, ...result };
});
@@ -10,15 +10,17 @@
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import type { RepositoryListResponse } from "#lib/generated/repository-api.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
parseTicketRecordRef,
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
TicketQueryItem as TicketSummary,
TicketBrowseQueryItem as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
@@ -44,19 +46,33 @@
let searchBusy = $state(false);
let pageBusy = $state(false);
let navigationError = $state<string | null>(null);
let routeGeneration = 0;
$effect(() => {
const currentData = data;
const generation = ++routeGeneration;
untrack(() => {
response = currentData.tickets;
searchText = currentData.search;
searchBusy = false;
pageBusy = false;
navigationError = null;
orchestrator = currentData.orchestrator;
orchestrator = { data: null, error: null };
orchestratorLoading = true;
orchestratorStarting = false;
repositoryResponse = { data: null, error: null };
repositoriesLoading = false;
creatingTicket = false;
createBusy = false;
createError = null;
createTitle = "";
createBody = "";
createTargets = [];
void loadOrchestrator(currentData, generation);
});
return () => { ++routeGeneration; };
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>({ data: null, error: null });
let orchestratorLoading = $state(true);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
@@ -64,8 +80,11 @@
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
let repositoryResponse = $state<ApiResult<RepositoryListResponse>>({ data: null, error: null });
let repositoriesLoading = $state(false);
const repositories = $derived(repositoryResponse.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
if (repositoriesLoading || !repositoryResponse.data) return false;
const keys = new Set<string>();
for (const target of createTargets) {
if (
@@ -77,9 +96,42 @@
}
return true;
});
function isCurrent(observedData: PageData, generation: number): boolean {
return data === observedData && routeGeneration === generation;
}
async function loadOrchestrator(observedData: PageData, generation: number): Promise<void> {
const result = await loadJson(
fetch, workspaceApiPath(observedData.workspaceId, "/orchestrator"), undefined,
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorLoading = false;
}
async function loadRepositories(): Promise<void> {
if (repositoriesLoading) return;
const observedData = data;
const generation = routeGeneration;
repositoriesLoading = true;
repositoryResponse = { data: null, error: null };
const result = await loadWorkspaceRepositoryList(fetch, observedData.workspaceId);
if (!isCurrent(observedData, generation)) return;
repositoryResponse = result;
repositoriesLoading = false;
}
function toggleCreate(): void {
creatingTicket = !creatingTicket;
if (creatingTicket && !repositoryResponse.data) void loadRepositories();
}
async function searchTickets(event?: SubmitEvent) {
event?.preventDefault();
if (searchBusy) return;
const observedData = data;
const generation = routeGeneration;
searchBusy = true;
navigationError = null;
try {
@@ -88,9 +140,11 @@
reset: false, refreshAll: true,
});
} catch (error) {
navigationError = error instanceof Error ? error.message : String(error);
if (isCurrent(observedData, generation)) {
navigationError = error instanceof Error ? error.message : String(error);
}
} finally {
searchBusy = false;
if (isCurrent(observedData, generation)) searchBusy = false;
}
}
@@ -106,21 +160,22 @@
async function loadMore(): Promise<void> {
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
const observedData = data;
const generation = routeGeneration;
const previous = response.data;
pageBusy = true;
response = { data: previous, error: null };
const result = await loadJson(
fetch,
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
workspaceApiPath(observedData.workspaceId, "/tickets/browse"),
{
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
},
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
if (data !== observedData) return;
if (!isCurrent(observedData, generation)) return;
response = result.data
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
: { data: previous, error: result.error };
@@ -152,6 +207,8 @@
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
const observedData = data;
const generation = routeGeneration;
createBusy = true;
createError = null;
const request: NewTicket = {
@@ -166,31 +223,38 @@
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
workspaceApiPath(observedData.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (!isCurrent(observedData, generation)) return;
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
if (isCurrent(observedData, generation)) {
createError = error instanceof Error ? error.message : String(error);
}
} finally {
createBusy = false;
if (isCurrent(observedData, generation)) createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
if (orchestratorLoading || orchestratorStarting || orchestrator.data?.online) return;
const observedData = data;
const generation = routeGeneration;
orchestratorStarting = true;
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
const result = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(data.workspaceId, "/orchestrator"),
workspaceApiPath(observedData.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorStarting = false;
}
@@ -214,13 +278,13 @@
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
onclick={toggleCreate}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<div class="orchestrator-status" data-online={orchestrator.data?.online} aria-busy={orchestratorLoading}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
<span>{orchestratorLoading ? "Checking…" : orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
@@ -240,8 +304,11 @@
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{#if repositoriesLoading}
<p role="status">Loading repositories…</p>
{:else if repositoryResponse.error}
<p class="workspace-callout is-error" role="alert">Repositories: {repositoryResponse.error}</p>
<button class="workspace-secondary-button" type="button" onclick={loadRepositories}>Retry repositories</button>
{/if}
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
@@ -0,0 +1,443 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { type ComponentProps, tick } from "svelte";
import { afterEach, expect, test, vi } from "vitest";
import type {
TicketBrowseQueryItem,
TicketBrowseQueryResponse,
} from "#lib/generated/ticket-api.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { compileTicketSearch } from "#lib/workspace/tickets/ticket-search.ts";
import TicketsPage from "./PageContent.svelte";
import { load } from "./+page.ts";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
type Data = ComponentProps<typeof TicketsPage>["data"];
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function ticket(id = "1", title = "First ticket"): TicketBrowseQueryItem {
return {
id,
resource_key: `T-${id}`,
title,
state: "open",
updated_at: null,
tags: [],
current_closure: null,
assignment_tracking: [],
};
}
function browse(
items = [ticket()],
cursor: string | null = null,
): TicketBrowseQueryResponse {
return {
items,
record_authority: "sqlite",
page: {
has_more: cursor !== null,
next_cursor: cursor,
limit: 50,
returned: items.length,
sort: "updated_desc",
source_limit: null,
source_truncated: false,
},
};
}
function data(
workspaceId = "workspace-a",
result = browse(),
search = "state:open",
): Data {
return {
workspaceId,
search,
queryRequest: compileTicketSearch(search, []),
tickets: { data: result, error: null },
} as Data;
}
function status(workspaceId = "workspace-a", online = true) {
return {
workspace_id: workspaceId,
online,
disposition: "observed",
worker: null,
diagnostics: [],
};
}
function repositories(workspaceId = "workspace-a") {
return {
workspace_id: workspaceId,
items: [],
source: "sqlite",
diagnostics: [],
};
}
function requests(routes: Record<string, () => Promise<Response>>) {
const request = vi.fn(
(input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const route = routes[String(input)];
if (!route) throw new Error(`Unexpected request: ${input}`);
return route();
},
);
vi.stubGlobal("fetch", request);
return request;
}
async function loadPage(
fetchFn: typeof fetch,
workspaceId = "workspace-a",
search = "state:open",
) {
const result = await load(
{
fetch: fetchFn,
params: { workspaceId },
url: new URL(
`https://example.test/w/${workspaceId}/tickets?q=${
encodeURIComponent(search)
}`,
),
} as Parameters<typeof load>[0],
).deferredPage;
if (!result.ok) throw new Error(result.failure.message);
return result.data as Data;
}
// Let response body decoding and Svelte effects finish before negative stale-result assertions.
async function flushResponses() {
await new Promise((resolve) => setTimeout(resolve, 0));
await tick();
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
test("actual loader posts browse only; rows render while orchestrator is unresolved and repositories stay lazy", async () => {
const rows = deferred<Response>();
const orchestrator = deferred<Response>();
const repos = deferred<Response>();
const request = requests({
"/api/w/workspace-a/tickets/browse": () => rows.promise,
"/api/w/workspace-a/orchestrator": () => orchestrator.promise,
"/api/w/workspace-a/repositories": () => repos.promise,
});
const pending = loadPage(request);
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
expect(request.mock.calls[0][1]?.method).toBe("POST");
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
compileTicketSearch("state:open", []),
);
rows.resolve(Response.json(browse()));
const view = render(TicketsPage, { data: await pending });
expect(await screen.findByText("First ticket")).toBeTruthy();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(
view.container.querySelector(".orchestrator-status")?.getAttribute(
"aria-busy",
),
).toBe("true");
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
expect(request.mock.calls.map(([path]) => path)).toEqual([
"/api/w/workspace-a/tickets/browse",
"/api/w/workspace-a/orchestrator",
]);
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(
request.mock.calls.filter(([path]) =>
String(path).endsWith("/repositories")
),
).toHaveLength(1);
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(screen.getByText("First ticket")).toBeTruthy();
repos.resolve(Response.json(repositories()));
orchestrator.resolve(Response.json(status()));
expect(await screen.findByText("Online")).toBeTruthy();
});
test("lazy repository HTTP failure is visible and retry enables targetless creation without reloading tickets", async () => {
const first = deferred<Response>();
const retry = deferred<Response>();
const repoRequest = vi.fn().mockReturnValueOnce(first.promise)
.mockReturnValueOnce(retry.promise);
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/repositories": repoRequest,
});
render(TicketsPage, { data: data() });
expect(repoRequest).not.toHaveBeenCalled();
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await fireEvent.input(screen.getByLabelText("Title"), {
target: { value: "New title" },
});
expect(
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
.disabled,
).toBe(true);
first.resolve(new Response("private failure body", { status: 503 }));
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"Repositories: Repository API request failed with HTTP 503",
);
expect(screen.queryByText(/private failure body/)).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Retry repositories" }),
);
expect(repoRequest).toHaveBeenCalledTimes(2);
expect(screen.getByText("Loading repositories…")).toBeTruthy();
retry.resolve(Response.json(repositories()));
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Create Ticket",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("First ticket")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(repoRequest).toHaveBeenCalledTimes(2);
expect(request.mock.calls.some(([path]) => String(path).includes("/tickets")))
.toBe(false);
});
test("failed status is Unavailable, not Offline, and does not hide rows", async () => {
const orchestrator = deferred<Response>();
requests({ "/api/w/workspace-a/orchestrator": () => orchestrator.promise });
render(TicketsPage, { data: data() });
expect(screen.getByText("Checking…")).toBeTruthy();
orchestrator.reject(new Error("Controlled status failure"));
expect(await screen.findByText("Unavailable")).toBeTruthy();
expect(screen.getByText("Orchestrator status: Controlled status failure"))
.toBeTruthy();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
expect(screen.getByText("First ticket")).toBeTruthy();
});
test("late prior-Workspace status and repository responses cannot replace current state or finish its pending load", async () => {
const oldStatus = deferred<Response>();
const oldRepos = deferred<Response>();
const newStatus = deferred<Response>();
const newRepos = deferred<Response>();
requests({
"/api/w/workspace-a/orchestrator": () => oldStatus.promise,
"/api/w/workspace-a/repositories": () => oldRepos.promise,
"/api/w/workspace-b/orchestrator": () => newStatus.promise,
"/api/w/workspace-b/repositories": () => newRepos.promise,
});
const view = render(TicketsPage, { data: data() });
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await view.rerender({
data: data(
"workspace-b",
browse([ticket("2", "Current Workspace ticket")]),
),
});
expect(screen.queryByText("New Ticket")).toBeNull();
expect(screen.queryByText("First ticket")).toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await fireEvent.input(screen.getByLabelText("Title"), {
target: { value: "Current draft" },
});
oldStatus.resolve(Response.json(status("workspace-a", false)));
oldRepos.resolve(Response.json(repositories("workspace-a")));
await flushResponses();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(screen.getByText("Current Workspace ticket")).toBeTruthy();
newStatus.resolve(Response.json(status("workspace-b")));
newRepos.resolve(Response.json(repositories("workspace-b")));
expect(await screen.findByText("Online")).toBeTruthy();
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Create Ticket",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect((screen.getByLabelText("Title") as HTMLInputElement).value).toBe(
"Current draft",
);
});
test("pagination posts browse with the query and cursor, preserves server order, and merges duplicate ids", async () => {
const next = deferred<Response>();
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/tickets/browse": () => next.promise,
});
const initial = data("workspace-a", browse([ticket()], "cursor-1"));
const view = render(TicketsPage, { data: initial });
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
const call = request.mock.calls.find(([path]) =>
String(path).endsWith("/tickets/browse")
)!;
expect(call[1]?.method).toBe("POST");
expect(JSON.parse(String(call[1]?.body))).toEqual({
...initial.queryRequest,
cursor: "cursor-1",
});
expect(screen.getByText("First ticket")).toBeTruthy();
next.resolve(
Response.json(
browse([
ticket("1", "Updated first ticket"),
ticket("2", "Second ticket"),
]),
),
);
expect(await screen.findByText("2 tickets loaded")).toBeTruthy();
expect(
[...view.container.querySelectorAll(".ticket-list-title")].map((node) =>
node.textContent
),
).toEqual(["Updated first ticket", "Second ticket"]);
expect(screen.queryByRole("button", { name: "Load more tickets" }))
.toBeNull();
});
for (const staleFailure of [false, true]) {
test(`prior-query pagination ${staleFailure ? "failure" : "success"} cannot overwrite current rows, cursor, or busy state`, async () => {
const oldPage = deferred<Response>();
const newPage = deferred<Response>();
const pageRequest = vi.fn().mockReturnValueOnce(oldPage.promise)
.mockReturnValueOnce(newPage.promise);
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/tickets/browse": pageRequest,
});
const view = render(TicketsPage, {
data: data("workspace-a", browse([ticket()], "old-cursor")),
});
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
const current = data(
"workspace-a",
browse([ticket("2", "Current query ticket")], "new-cursor"),
"state:closed",
);
await view.rerender({ data: current });
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
oldPage.resolve(
staleFailure
? new Response("stale error", { status: 500 })
: Response.json(
browse([ticket("3", "Stale page ticket")], "stale-cursor"),
),
);
await flushResponses();
expect(screen.queryByText("Stale page ticket")).toBeNull();
expect(screen.queryByText("First ticket")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("Current query ticket")).toBeTruthy();
expect(screen.getByText("Loading…")).toBeTruthy();
expect(
screen.getByRole("region", { name: "Tickets" }).getAttribute("aria-busy"),
).toBe("true");
const calls = request.mock.calls.filter(([path]) =>
String(path).endsWith("/tickets/browse")
);
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
...current.queryRequest,
cursor: "new-cursor",
});
newPage.resolve(Response.json(browse([ticket("4", "Current next page")])));
expect(await screen.findByText("Current next page")).toBeTruthy();
expect(screen.getByText("2 tickets loaded")).toBeTruthy();
expect(screen.queryByText("Loading…")).toBeNull();
});
}
test("controlled loader-to-component timing isolates the former blocking Promise.all dependency gate", async () => {
// A local dependency-gating comparison, NOT a production latency benchmark.
// Both arms use the actual current browse loader/parser and real Svelte component.
// The baseline restores only the old Promise.all wait for status + repositories;
// it deliberately does not emulate the old enriched query's backend work.
const delays = { tickets: 20, secondary: 100 };
const measurements: Record<string, number> = {};
for (const blocking of [true, false]) {
const outstanding: Promise<Response>[] = [];
const delayed = (payload: unknown, ms: number) => {
const promise = new Promise<Response>((resolve) =>
setTimeout(() => resolve(Response.json(payload)), ms)
);
outstanding.push(promise);
return promise;
};
const request = requests({
"/api/w/workspace-a/tickets/browse": () =>
delayed(browse(), delays.tickets),
"/api/w/workspace-a/orchestrator": () =>
delayed(status(), delays.secondary),
"/api/w/workspace-a/repositories": () =>
delayed(repositories(), delays.secondary),
});
const start = performance.now();
const page = blocking
? (await Promise.all([
loadPage(request),
loadJson(
request,
workspaceApiPath("workspace-a", "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
loadWorkspaceRepositoryList(request, "workspace-a"),
]))[0]
: await loadPage(request);
render(TicketsPage, { data: page });
expect(await screen.findByText("First ticket")).toBeTruthy();
measurements[blocking ? "blockingBaselineMs" : "actualLoaderMs"] = Number(
(performance.now() - start).toFixed(2),
);
if (!blocking) {
expect(
request.mock.calls.some(([path]) =>
String(path).endsWith("/repositories")
),
).toBe(false);
}
cleanup();
await Promise.all(outstanding);
await flushResponses();
}
// No speed threshold: scheduler noise must not turn this into a flaky performance gate.
console.info(
"Controlled mocked loader-to-component timing (not production):",
JSON.stringify({ delaysMs: delays, ...measurements }),
);
});