web: launch embedded workspace orchestrator
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -108,9 +108,10 @@ export async function loadWorkspaceSkillDetail(
|
||||
export async function loadJson<T>(
|
||||
fetchFn: typeof fetch,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<ApiResult<T>> {
|
||||
try {
|
||||
const response = await fetchFn(path);
|
||||
const response = await fetchFn(path, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return {
|
||||
|
||||
@@ -73,6 +73,42 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.ticket-panel-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.orchestrator-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
.orchestrator-status > div {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
.orchestrator-status strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.orchestrator-status span {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.orchestrator-status-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #a75454;
|
||||
}
|
||||
.orchestrator-status[data-online="true"] .orchestrator-status-dot {
|
||||
background: #43a66d;
|
||||
}
|
||||
.ticket-panel-summary {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
|
||||
@@ -10,8 +10,15 @@ import type {
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
readTextFile(path: string): Promise<string>;
|
||||
};
|
||||
|
||||
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(
|
||||
@@ -97,3 +104,20 @@ 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(detailSource, "{#if orchestratorOnline}");
|
||||
assertIncludes(detailSource, "!orchestratorOnline");
|
||||
assertIncludes(detailSource, "Orchestrator offline");
|
||||
});
|
||||
|
||||
@@ -12,6 +12,23 @@ export const TICKET_STATES = [
|
||||
export type TicketState = (typeof TICKET_STATES)[number];
|
||||
export type TicketWorkerRole = "coder" | "reviewer";
|
||||
|
||||
export type WorkspaceOrchestratorStatus = {
|
||||
workspace_id: string;
|
||||
online: boolean;
|
||||
disposition: string;
|
||||
worker?: {
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
state: string;
|
||||
display_name: string;
|
||||
} | null;
|
||||
diagnostics: Array<{
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const LANE_DEFINITIONS = [
|
||||
{
|
||||
id: "ready-planning",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user