web: launch embedded workspace orchestrator

This commit is contained in:
2026-08-04 19:52:54 +09:00
parent da90ac74b4
commit dd2ca54874
14 changed files with 384 additions and 28 deletions
@@ -1,7 +1,11 @@
<script lang="ts">
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { ticketLanes } from "$lib/workspace/tickets/ticket-panel";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
ticketLanes,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type {
TicketListResponse,
TicketSummary,
@@ -11,13 +15,29 @@
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
let tickets = $state<TicketSummary[]>(initialTickets);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let lanes = $derived(ticketLanes(tickets));
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
orchestratorStarting = true;
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(data.workspaceId, "/orchestrator"),
{ method: "POST" },
);
orchestratorStarting = false;
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
@@ -36,12 +56,41 @@
Plan, route, review, and close work without leaving the workspace.
</p>
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
<div class="ticket-panel-controls">
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data?.online ? "Online" : "Offline"}</span>
</div>
{#if !orchestrator.data?.online}
<button
class="workspace-primary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
>
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
</button>
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
</div>
</div>
</header>
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{:else if !orchestrator.data?.online}
<p class="workspace-callout">
Orchestration actions are unavailable until the embedded Orchestrator is online.
</p>
{/if}
<section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)}
<section class="ticket-lane" data-state={lane.id}>
@@ -1,15 +1,23 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const tickets = await loadJson<TicketListResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
);
const [tickets, orchestrator] = await Promise.all([
loadJson<TicketListResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
),
]);
return {
workspaceId: params.workspaceId,
tickets,
orchestrator,
};
}) satisfies PageLoad;
@@ -9,6 +9,7 @@
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
import type {
@@ -22,6 +23,7 @@
ticketId: string;
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
@@ -29,6 +31,7 @@
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
let ticket = $state<TicketDetail>(loadedTicket);
let editing = $state(false);
@@ -270,11 +273,19 @@
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
<p>The common launch flow carries a short canonical Ticket message and the target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
</div>
{#if orchestratorOnline}
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
</div>
{:else}
<p class="workspace-callout">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>
<section class="ticket-control-card">
@@ -309,8 +320,8 @@
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue"} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : "Queue ticket"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
</button>
{/if}
</section>
@@ -1,4 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type {
RepositoryListResponse,
TicketDetail,
@@ -6,7 +7,7 @@ import type {
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const [ticket, repositories] = await Promise.all([
const [ticket, repositories, orchestrator] = await Promise.all([
loadJson<TicketDetail>(
fetch,
workspaceApiPath(
@@ -18,6 +19,10 @@ export const load = (async ({ fetch, params }) => {
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
),
]);
return {
@@ -25,5 +30,6 @@ export const load = (async ({ fetch, params }) => {
ticketId: params.ticketId,
ticket,
repositories,
orchestrator,
};
}) satisfies PageLoad;