feat: generalize ticket role assignments

This commit is contained in:
2026-08-22 22:48:56 +09:00
parent 4017992c7d
commit 8030045602
9 changed files with 2250 additions and 489 deletions
+28 -217
View File
@@ -1,241 +1,52 @@
// Generated from yoi-workspace-server. Do not edit by hand.
// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts
export type InvalidProjectRecord = { label: string; reason: string };
export type InvalidProjectRecord = { label: string, reason: string, };
export type TicketSummary = {
id: string;
resource_key: string;
title: string;
state: string;
priority: string;
updated_at: string | null;
queued_by: string | null;
queued_at: string | null;
workspace_action_priority: string;
record_source: string;
};
export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, };
export type TicketListResponse = {
workspace_id: string;
limit: number;
items: Array<TicketSummary>;
page: QueryPage;
invalid_records: Array<InvalidProjectRecord>;
record_authority: string;
};
export type TicketListResponse = { workspace_id: string, limit: number, items: Array<TicketSummary>, page: QueryPage, invalid_records: Array<InvalidProjectRecord>, record_authority: string, };
export type QueryPage = {
limit: number;
returned: number;
has_more: boolean;
next_cursor: string | null;
sort: string;
source_limit: number | null;
source_truncated: boolean;
};
export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, };
export type TicketEventDetail = {
sequence: number;
event_ref: string;
kind: string;
author: string | null;
at: string | null;
status: string | null;
from: string | null;
to: string | null;
reason: string | null;
state_field: string | null;
heading: string | null;
body: string | null;
attributes: { [key in string]: string };
references: Array<string>;
};
export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array<string>, };
export type ObjectiveLinkSummary = {
id: string;
resource_key: string;
title: string;
state: string;
};
export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, };
export type TicketEvidenceEvent = {
event_ref: string;
sequence: number;
kind: string;
at: string | null;
author: string | null;
excerpt: string;
};
export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, };
export type TicketAssignmentSummary = {
assignment_id: string;
runtime_id: string;
worker_id: string;
worker_resource_key?: string | null;
};
export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, };
export type TicketMergeRequestSummary = {
merge_request_id: string;
repository_id: string;
state: string;
review_status: string;
selector_from: string | null;
selector_to: string;
updated_at: string;
current_subject_ref: string | null;
review_subject_ref: string | null;
review_requested_at: string | null;
review_submitted_at: string | null;
review_excerpt: string | null;
};
export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, };
export type MergeRequestListItem = {
summary: TicketMergeRequestSummary;
ticket_ids: Array<string>;
thread_event_count: number;
};
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
export type MergeRequestListResponse = {
items: Array<MergeRequestListItem>;
next_cursor: string | null;
};
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
export type TicketEvidenceSummary = {
has_merge_request: boolean;
has_current_subject_ref: boolean;
has_review_request: boolean;
has_commit: boolean;
review_status: string | null;
approved_current_subject: boolean;
review_after_rescope: boolean;
unresolved_request_changes: boolean;
complete_for_integration: boolean;
missing: Array<string>;
};
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
export type TicketQueryRequest = {
query: string | null;
states: Array<string>;
event_kinds: Array<string>;
evidence: Array<string>;
review_status: string | null;
attention: Array<string>;
related_ticket_id: string | null;
relation_kind: string | null;
linked_objective_id: string | null;
updated_after: string | null;
updated_before: string | null;
sort: string | null;
limit: number | null;
cursor: string | null;
};
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
export type TicketQueryItem = {
id: string;
resource_key: string;
title: string;
state: string;
readiness: string | null;
priority: string;
created_at: string | null;
updated_at: string | null;
item_revision: string;
workspace_action_priority: string;
matched_fields: Array<string>;
snippet: string | null;
matching_event: TicketEvidenceEvent | null;
linked_objective_ids: Array<string>;
relation_count: number;
blocker_count: number;
unresolved_blocker_count: number;
unresolved_review_count: number;
evidence: TicketEvidenceSummary;
merge_request: TicketMergeRequestSummary | null;
};
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
export type TicketQueryResponse = {
items: Array<TicketQueryItem>;
page: QueryPage;
record_authority: string;
};
export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array<string>, };
export type TicketShowRequest = {
event_limit: number | null;
event_cursor: string | null;
};
export type TicketQueryRequest = { query: string | null, states: Array<string>, event_kinds: Array<string>, evidence: Array<string>, review_status: string | null, attention: Array<string>, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, };
export type TicketRelation = {
ticket_id: string;
kind: string;
target: string;
target_resource_key?: string | null;
note: string | null;
author: string;
at: string;
};
export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array<string>, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array<string>, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, };
export type DerivedTicketRelation = {
source_ticket: string;
source_resource_key?: string | null;
inverse_kind: string;
forward_kind: string;
note: string | null;
author: string;
at: string;
};
export type TicketQueryResponse = { items: Array<TicketQueryItem>, page: QueryPage, record_authority: string, };
export type TicketRelationBlocker = {
blocking_ticket: string;
blocking_resource_key?: string | null;
reason_kind: string;
relation_kind: string;
note: string | null;
blocking_state: string;
};
export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, };
export type TicketRelationNotice = {
related_ticket: string;
kind: string;
message: string;
};
export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, };
export type TicketRelationView = {
outgoing: Array<TicketRelation>;
incoming: Array<DerivedTicketRelation>;
blockers: Array<TicketRelationBlocker>;
notices: Array<TicketRelationNotice>;
};
export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, };
export type TicketDetail = {
id: string;
resource_key: string;
title: string;
state: string;
readiness: string | null;
priority: string;
created_at: string | null;
updated_at: string | null;
item_revision: string;
queued_by: string | null;
queued_at: string | null;
assignee: string | null;
repository_id: string | null;
ref_selector: string | null;
risk_flags: Array<string>;
body: string;
body_truncated: boolean;
event_count: number;
events: Array<TicketEventDetail>;
event_page: QueryPage;
artifact_count: number;
artifacts: Array<string>;
relations: TicketRelationView;
linked_objectives: Array<ObjectiveLinkSummary>;
implementation_reports: Array<TicketEvidenceEvent>;
current_assignment: TicketAssignmentSummary | null;
merge_request: TicketMergeRequestSummary | null;
evidence: TicketEvidenceSummary;
resolution: string | null;
record_source: string;
};
export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, };
export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, };
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_id: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
@@ -110,6 +110,25 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
);
});
Deno.test("ticket detail uses server-derived role assignment actions", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
true,
);
assertEquals(source.includes("/assignments/${role}"), true);
assertEquals(source.includes('kind: "workspace_agent"'), true);
assertEquals(source.includes('kind: "worker"'), true);
assertEquals(source.includes("ticket.assignee"), false);
});
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
const css = await Deno.readTextFile(
new URL("../styles/tickets.css", import.meta.url),
@@ -2,6 +2,7 @@
import { untrack } from "svelte";
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
@@ -9,7 +10,6 @@
import {
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
@@ -37,7 +37,6 @@
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);
const mergeRequest = $derived(ticket.merge_request);
@@ -54,6 +53,8 @@
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
);
@@ -72,7 +73,7 @@
),
);
const implementationStartEligible = $derived(
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
ticket.action_eligibility.can_start_manual_coder,
);
const ticketPath = $derived(
@@ -116,6 +117,51 @@
}
}
async function mutateAssignment(
action: string,
role: "orchestrator" | "coder",
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
busy = action;
errorMessage = null;
try {
await workspaceApiJsonWithBody(
`${ticketPath}/assignments/${role}`,
{
method: "PUT",
body: JSON.stringify({
operation_id: crypto.randomUUID(),
principal,
expected_assignment_id: null,
}),
},
);
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
busy = null;
}
}
async function assignOrchestrator(): Promise<void> {
await mutateAssignment("assign-orchestrator", "orchestrator", {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
}
async function startManualCoder(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
await mutateAssignment("start-manual", "coder", {
kind: "worker",
runtime_id: manualRuntimeId.trim(),
worker_id: manualWorkerId.trim(),
});
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
@@ -326,24 +372,54 @@
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-worker-card">
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</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>
<header><h2>Role assignments</h2><span>Server-authoritative</span></header>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
{#each ticket.assignments as assignment}
<li>
<strong>{assignment.role}</strong>
<span>
{#if assignment.principal.kind === "worker"}
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
{:else if assignment.principal.kind === "user"}
{assignment.principal.account_id}
{:else}
{assignment.principal.agent_key}
{/if}
</span>
</li>
{/each}
</ul>
{:else}
<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>
</div>
<p class="workspace-empty-copy">No active role assignment.</p>
{/if}
{#if ticket.action_eligibility.can_assign_orchestrator}
<button
class="workspace-primary-button"
type="button"
disabled={busy !== null}
onclick={assignOrchestrator}
>
{busy === "assign-orchestrator" ? "Assigning…" : "Assign Orchestrator"}
</button>
{/if}
{#if implementationStartEligible}
<form class="ticket-control-form" onsubmit={startManualCoder}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<button
class="workspace-secondary-button"
type="submit"
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
>
{busy === "start-manual" ? "Starting…" : "Assign Coder and start"}
</button>
</form>
{/if}
{#if ticket.assignment_diagnostics.length > 0}
{#each ticket.assignment_diagnostics as diagnostic}
<p class="workspace-callout">{diagnostic}</p>
{/each}
{/if}
</section>
@@ -386,9 +462,12 @@
<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 class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : "Queue ticket"}
</button>
{#if !ticket.action_eligibility.can_queue}
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
{/if}
{/if}
</section>