merge: integrate orchestration

# Conflicts:
#	crates/flow/src/builtin.rs
#	crates/manifest/src/profile.rs
#	crates/worker/src/prompt/catalog.rs
#	crates/worker/src/prompt/system.rs
#	resources/flows/coder-review.dcdl
#	resources/prompts/role/coder.md
#	resources/prompts/role/orchestrator.md
#	web/workspace/src/lib/workspace/console/worker-console.ui.test.ts
#	web/workspace/src/lib/workspace/styles/tickets.css
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte
#	web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
This commit is contained in:
2026-08-18 09:55:27 +09:00
37 changed files with 2964 additions and 727 deletions
@@ -19,6 +19,7 @@ export type TicketListResponse = {
workspace_id: string;
limit: number;
items: Array<TicketSummary>;
page: QueryPage;
invalid_records: Array<InvalidProjectRecord>;
record_authority: string;
};
@@ -178,7 +178,9 @@
align-content: start;
gap: 0.55rem;
min-height: 0;
max-height: min(68vh, 48rem);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.6rem;
scrollbar-gutter: stable;
}
@@ -189,6 +191,27 @@
font-size: 0.7rem;
text-align: center;
}
.ticket-lane-page-state {
display: flex;
justify-content: center;
gap: 0.5rem;
margin: 0;
padding: 0.45rem;
color: var(--text-muted);
font-size: 0.72rem;
text-align: center;
}
.ticket-lane-page-error {
align-items: center;
color: var(--danger);
}
.ticket-lane-page-error button {
border: 1px solid var(--line);
border-radius: 0.35rem;
background: var(--bg-raised);
color: inherit;
padding: 0.2rem 0.45rem;
}
.ticket-card {
display: grid;
gap: 0.55rem;
@@ -11,16 +11,9 @@ import type {
} from "../../generated/ticket-api.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string): Promise<string>;
test(name: string, fn: () => void): void;
};
function assertIncludes(actual: string, expected: string): void {
if (!actual.includes(expected)) {
throw new Error(`expected source to include ${JSON.stringify(expected)}`);
}
}
function assertEquals<T>(actual: T, expected: T): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
@@ -115,29 +108,3 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
);
});
Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => {
const panelSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/+page.svelte",
);
const detailSource = await Deno.readTextFile(
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
);
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/orchestrator")',
);
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
assertIncludes(panelSource, "lane.tickets.slice(0, lane.visibleCount)");
assertIncludes(
panelSource,
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
);
assertIncludes(panelSource, "Scroll for");
assertIncludes(detailSource, "{#if orchestratorOnline}");
assertIncludes(detailSource, "!orchestratorOnline");
assertIncludes(detailSource, "Orchestrator offline");
});
@@ -1,44 +1,95 @@
<script lang="ts">
import { untrack } from "svelte";
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 type {
QueryPage,
TicketListResponse,
TicketSummary,
} from "$lib/generated/ticket-api";
import {
nextTicketLaneVisibleCount,
TICKET_LANE_PAGE_SIZE,
ticketLanes,
type TicketLane,
type TicketLaneId,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import "$lib/workspace/styles/tickets.css";
import type { PageData } from "./$types";
type VisibleTicketLane = TicketLane & { visibleCount: number };
type LaneState = {
states: string[];
tickets: TicketSummary[];
page: QueryPage;
loading: boolean;
error: string | null;
};
const { data } = $props<{
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
let lanes = $state<VisibleTicketLane[]>(
untrack(() =>
ticketLanes(data.tickets.data?.items ?? []).map((lane) => ({
...lane,
visibleCount: Math.min(TICKET_LANE_PAGE_SIZE, lane.tickets.length),
}))
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
Object.fromEntries(
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
laneId,
{
states: [...lane.states],
tickets: lane.response.items,
page: lane.response.page,
loading: false,
error: null,
},
]),
),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
const displayedTicketCount = $derived(
lanes.reduce((count, lane) => count + lane.visibleCount, 0),
const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
const LANE_LOAD_THRESHOLD_PX = 96;
const lanes = $derived(ticketLanes(tickets));
function mergeTickets(
current: TicketSummary[],
incoming: TicketSummary[],
): TicketSummary[] {
const byId = new Map(current.map((ticket) => [ticket.id, ticket]));
for (const ticket of incoming) byId.set(ticket.id, ticket);
return [...byId.values()];
}
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
return;
}
lane.loading = true;
lane.error = null;
try {
const search = new URLSearchParams({
limit: "30",
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
lane.loading = false;
}
}
function handleLaneScroll(event: Event, laneId: string): void {
const container = event.currentTarget as HTMLElement;
const remaining =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (remaining <= 96) void loadMore(laneId);
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
@@ -56,37 +107,20 @@
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
function revealNextTickets(laneId: TicketLaneId): void {
lanes = lanes.map((lane) =>
lane.id === laneId
? {
...lane,
visibleCount: nextTicketLaneVisibleCount(
lane.visibleCount,
lane.tickets.length,
),
}
: lane
);
}
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
const element = event.currentTarget as HTMLElement;
const distanceFromBottom = element.scrollHeight - element.scrollTop -
element.clientHeight;
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
revealNextTickets(laneId);
}
}
</script>
<svelte:head><title>Tickets · Yoi</title></svelte:head>
<svelte:head>
<title>Tickets · {data.workspaceId}</title>
</svelte:head>
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<p class="workspace-eyebrow">Delivery</p>
<h1>Tickets</h1>
<p class="workspace-page-lede">
Plan, route, review, and close work without leaving the workspace.
</p>
</div>
<div class="ticket-panel-controls">
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
@@ -107,16 +141,12 @@
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{displayedTicketCount}</strong>
<span>tickets displayed</span>
<strong>{tickets.length}</strong>
<span>loaded tickets</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}
@@ -129,24 +159,21 @@
<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}
{@const pagination = laneState[lane.id]}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">
{displayedTickets.length}{hasMore ? "+" : ""}
</span>
<span class="ticket-lane-count">{lane.tickets.length}</span>
</header>
<div
class="ticket-lane-cards"
data-lane-id={lane.id}
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each displayedTickets as ticket (ticket.id)}
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
@@ -161,13 +188,15 @@
{:else}
<div class="ticket-lane-empty">No tickets</div>
{/each}
{#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 pagination?.loading}
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
{:else if pagination?.error}
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
<span>{pagination.error}</span>
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
</div>
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if}
</div>
</section>
@@ -1,14 +1,43 @@
import type { TicketListResponse } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const LANE_STATES = {
"ready-planning": ["ready", "planning"],
"inprogress-queued": ["inprogress", "queued"],
"done-closed": ["done", "closed"],
} as const;
export type TicketLaneId = keyof typeof LANE_STATES;
export type TicketLanePage = {
states: readonly string[];
response: TicketListResponse;
};
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [tickets, orchestrator] = await Promise.all([
loadJson<TicketListResponse>(
fetch,
`${workspaceApiPath(workspaceId, "/tickets")}?limit=1000`,
const [entries, orchestrator] = await Promise.all([
Promise.all(
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
@@ -16,5 +45,12 @@ export const load = (async ({ fetch, params }) => {
),
]);
return { workspaceId, tickets, orchestrator };
}) satisfies PageLoad;
return {
workspaceId,
ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
orchestrator,
};
};
@@ -14,6 +14,7 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "$lib/workspace/sidebar/types";
@@ -65,7 +66,9 @@
thread: MergeRequestThreadEvent[];
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
const { data } = $props<{
data: {
@@ -115,6 +118,27 @@
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
);
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
const targetCandidateValid = $derived(
ticket.state === "planning" &&
selectedRepository !== null &&
(selectedRepository.diagnostics ?? []).length === 0 &&
effectiveRefSelector.length > 0,
);
const persistedTargetValid = $derived(
ticket.repository_id !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
),
);
const implementationStartEligible = $derived(
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
);
const ticketPath = $derived(
workspaceApiPath(
@@ -180,6 +204,33 @@
}, "PATCH");
}
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_id !== repositoryId ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
transitionReason = "";
}
}
async function transition(event: SubmitEvent) {
event.preventDefault();
if (
@@ -329,16 +380,19 @@
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
{#if orchestratorOnline}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
{#if orchestratorOnline && implementationStartEligible}
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
</div>
{:else}
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
<p class="workspace-callout">
{orchestratorOnline
? "Validate and persist the repository target before starting a Ticket Worker."
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
</p>
<div class="ticket-role-actions">
<button class="workspace-primary-button" type="button" disabled>Coder</button>
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
</div>
{/if}
</section>
@@ -347,15 +401,15 @@
<header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryId}>
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"}
</button>
</form>
@@ -374,8 +428,15 @@
Apply state
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
{#if ticket.state === "planning"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
</button>
{/if}