feat: connect Subject Ticket assignments across public execution paths

This commit is contained in:
2026-10-30 00:11:02 +09:00
parent 7ad6030498
commit 6733aeef01
91 changed files with 8938 additions and 1731 deletions
+1
View File
@@ -13,6 +13,7 @@
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
"test:browser:subject-handling": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/subject-handling.browser.test.ts",
"test:ticket:real-api": "cd ../.. && deno task test:ticket:real-api",
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
"build": "deno run -A npm:vite@8.3.2 build",
@@ -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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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
@@ -107,9 +107,9 @@ export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "
export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; };
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipal = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentPrincipalSnapshot = { kind: "subject"; subject_id: string; } | { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; };
@@ -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:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f)
// Input contract: 0.1.0 (sha256:f2ecdab70235cce3230dfc84579f5d7e7b69884308bb1286337d071aa337fc38)
// 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
@@ -8,6 +8,7 @@ import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
parseTicketEvent,
parseAssignmentTrackingProjection,
parseMergeRequestDetailResponse,
parseMergeRequestListResponse,
parseObjectiveListResponse,
@@ -862,3 +863,45 @@ Deno.test("case decoders reject legacy current states and malformed closure judg
"closure_operation_key must be a string",
);
});
Deno.test("Subject assignment projection preserves identity and legacy audit without singleton normalization", () => {
const base = {
workspace_id: "w",
ticket_id: "t",
assignment_id: "a",
role: "orchestrator",
assigned_by: "Mika",
assigned_at: "2026-01-01T00:00:00Z",
current: true,
tracking: { status: "waiting" },
last_event_id: "e",
};
for (
const principal of [
{ kind: "subject", subject_id: "subject-a" },
{ kind: "subject", subject_id: "subject-b" },
{ kind: "user", account_id: "alice" },
{ kind: "worker", runtime_id: "r", worker_id: "w" },
{ kind: "workspace_agent", agent_key: "workspace-orchestrator" },
]
) {
assertEquals(
parseAssignmentTrackingProjection({ ...base, principal }, "assignment")
.principal,
principal,
);
}
for (
const principal of [
{ kind: "subject" },
{ kind: "subject", subject_id: 3 },
{ kind: "subject", subject_id: "a", worker_id: "w" },
]
) {
assertThrows(
() =>
parseAssignmentTrackingProjection({ ...base, principal }, "assignment"),
"assignment.principal",
);
}
});
@@ -578,6 +578,10 @@ function parseTicketAssignmentPrincipal(
): TicketAssignmentPrincipal {
const item = object(value, label);
const kind = string(item.kind, `${label}.kind`);
if (kind === "subject") {
exact(item, ["subject_id", "kind"], label);
return { kind, subject_id: string(item.subject_id, `${label}.subject_id`) };
}
if (kind === "user") {
exact(item, ["account_id", "kind"], label);
return { kind, account_id: string(item.account_id, `${label}.account_id`) };
@@ -53,7 +53,7 @@ const current: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1",
role: "orchestrator",
current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
assigned_by: "account/user",
assigned_at: "2026-01-01T00:00:00Z",
tracking: {
@@ -82,12 +82,14 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
"ticket/one",
old,
"operation-1",
false,
{ kind: "subject", subject_id: "subject-a" },
);
old.assignments[0].assignment_id = "replacement";
assertEquals(JSON.parse(operation.body!), {
operation_id: "operation-1",
expected_assignment_id: "assignment-1",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
});
assertEquals(
operation.path,
@@ -99,6 +101,8 @@ Deno.test("assignment retry retains the exact observed pointer, operation key an
"ticket/one",
old,
"operation-2",
false,
{ kind: "subject", subject_id: "subject-b" },
);
assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement");
const clear = assignmentOperation(
@@ -273,3 +277,38 @@ Deno.test("conflict, permission, unavailable and unknown errors never claim succ
"Outcome unknown",
);
});
Deno.test("new Assign requires an explicit Subject; legacy assignments remain releasable audit", () => {
assertThrows(() =>
assignmentOperation("team", "ticket", observed(), "missing")
);
assertThrows(() =>
assignmentOperation("team", "ticket", observed(), "empty", false, {
kind: "subject",
subject_id: " ",
})
);
for (
const principal of [{ kind: "user", account_id: "alice" }, {
kind: "worker",
runtime_id: "r",
worker_id: "w",
}, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
}] as const
) {
assertThrows(() =>
answerOperation("team", "ticket", { ...current, principal }, "old", "op")
);
const operation = assignmentOperation(
"team",
"ticket",
{ assignments: [{ ...observed().assignments[0], principal }] },
"release",
true,
);
assertEquals(operation.method, "DELETE");
assertStringIncludes(operation.path, "assignment_id=assignment-1");
}
});
@@ -104,10 +104,10 @@ export function assignmentOperation(
observed: Pick<TicketDetail, "assignments">,
operationId: string,
clear = false,
principal: SetTicketRoleAssignmentRequest["principal"] = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
},
principal?: Extract<
SetTicketRoleAssignmentRequest["principal"],
{ kind: "subject" }
>,
): ObservedOperation {
const current = observed.assignments.find((row) =>
row.role === "orchestrator"
@@ -126,6 +126,11 @@ export function assignmentOperation(
"DELETE",
);
}
if (
!principal || principal.kind !== "subject" || !principal.subject_id.trim()
) {
throw new Error("Select a Subject for the new assignment");
}
const request: SetTicketRoleAssignmentRequest = {
operation_id: operationId,
expected_assignment_id: current?.assignment_id ?? null,
@@ -212,7 +217,8 @@ export function answerOperation(
operationKey: string,
): ObservedOperation {
if (
!observed.current || !observed.last_event_id || !observed.tracking ||
observed.principal.kind !== "subject" || !observed.current ||
!observed.last_event_id || !observed.tracking ||
observed.tracking.status === "done"
) throw new Error("Read actionable current assignment before answering");
const request: TicketAssignmentAnswer = {
@@ -13,7 +13,7 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
const row: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1", role: "orchestrator", ticket_id: "internal-T-1", workspace_id: "team",
assigned_at: "2026-10-13T09:00:00Z", assigned_by: "Mika", current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, last_event_id: "handling-1",
principal: { kind: "subject", subject_id: "subject-a" }, last_event_id: "handling-1",
tracking: { status, waiting_reason: "Choose the policy", progress: "Compared options", next_action: "Confirm the choice", result: "Comparison delivered" },
};
return { ...fixtureDetail("T-1"), assignment_tracking: [row],
@@ -24,6 +24,7 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }, emptyAssignments = true) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path.includes("/subjektiv/subjects")) return Response.json(subjectPage);
if (emptyAssignments && path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
return await handle(path, init);
});
@@ -31,7 +32,7 @@ function installFetch(handle: (path: string, init?: RequestInit) => Response | P
return fetcher;
}
function mount(ticket = assigned(), refresh = vi.fn(async () => {})) {
return { ...render(TicketHandling, { workspaceId: "team", ticket, orchestrator: null, refresh }), refresh };
return { ...render(TicketHandling, { workspaceId: "team", ticket, refresh }), refresh };
}
function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: ReturnType<typeof mount>) {
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
@@ -43,10 +44,10 @@ function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: Ret
test("mount retains assignment and self-reported handling, progress and result without delivery UI or diagnostics reads", async () => {
const fetcher = installFetch();
const view = mount();
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
for (const text of ["Choose the policy", "Compared options", "Confirm the choice", "Comparison delivered", "Assignment details"]) expect(view.getByText(text)).toBeTruthy();
expect(view.container.querySelector(".ticket-orchestrator-worker")).not.toBeNull();
expect(view.container.querySelector(".ticket-subject-worker")).not.toBeNull();
expectNoDiagnostics(fetcher, view);
});
@@ -56,13 +57,14 @@ test("assignment save refreshes assignment history without diagnostics or delive
expect(init?.method).toBe("PUT");
return Response.json({ ticket_id: "internal-T-1", workspace_id: "team", assignment: {
assignment_id: "assignment-2", assigned_at: "2026-10-13T10:00:00Z", assigned_by: "Mika", role: "orchestrator",
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "subject", subject_id: "subject-a" },
} });
});
const view = mount(assigned("done"));
await chooseSubject(view);
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -81,7 +83,7 @@ test("answer save keeps handling unchanged and refreshes history without diagnos
await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use read-only comparison" } });
await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -93,11 +95,12 @@ test("answer save keeps handling unchanged and refreshes history without diagnos
test("uncertain assignment reconciliation only refreshes the projection and assignment history", async () => {
const fetcher = installFetch(() => { throw new Error("Response lost"); });
const view = mount(assigned("done"));
await chooseSubject(view);
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Outcome unknown"));
await fireEvent.click(view.getByRole("button", { name: "Refresh and reconcile assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(fetcher).toHaveBeenCalledTimes(4);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
@@ -186,10 +189,10 @@ test("released done is retained as past result without current Answer or implici
await fireEvent.click(view.getByRole("button", { name: /Past · done/ }));
await waitFor(() => expect(view.getByText(/unassigned by Mika/)).toBeTruthy());
expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy();
expect(view.getByText("No current Orchestrator assignment.")).toBeTruthy();
expect(view.getByText("No current Subject assignment.")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(view.queryByRole("button", { name: "Read replacement assignment" })).toBeNull();
expect(view.getByRole("button", { name: "Request Orchestrator" })).toBeTruthy();
expect(view.getByRole("button", { name: "Assign Subject" })).toBeTruthy();
expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === "GET")).toBe(true);
expectNoDiagnostics(fetcher, view);
});
@@ -198,15 +201,15 @@ test("case close and reopen keep done result non-actionable and require an expli
const fetcher = installFetch();
const ticket = assigned("done");
const view = mount(ticket);
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
for (const state of ["closed", "open"] as const) {
await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, orchestrator: null, refresh: view.refresh });
await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, refresh: view.refresh });
expect(view.getByText(`This handling was returned. Ticket ${state} is unchanged.`)).toBeTruthy();
expect(view.getByText("Comparison delivered")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(view.getByRole("button", { name: "Request again (new assignment)" })).toBeTruthy();
}
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(view.refresh).not.toHaveBeenCalled();
expectNoDiagnostics(fetcher, view);
});
@@ -241,3 +244,24 @@ test("releasing responsibility while reading an old result uses current assignme
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1);
expectNoDiagnostics(fetcher, view);
});
const subjectPage = { limit: 100, items: [{ id: "subject-a", role: "Research", state: "active", behavior_md: "", memory_fingerprint: "f", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", current_worker: null }], has_more: false, next_cursor: null };
async function chooseSubject(view: ReturnType<typeof mount>) {
await fireEvent.click(view.getByText("Choose responsible Subject"));
await waitFor(() => expect(view.queryByText("Loading Subjects…")).toBeNull());
// Current Subject is the observed reissue candidate; different-Subject native selection is covered in Playwright.
}
test("legacy principal is audit-only with no Answer or mandatory singleton observation", async () => {
const ticket = assigned();
ticket.assignment_tracking[0].principal = { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
ticket.assignments[0].principal = ticket.assignment_tracking[0].principal;
const fetcher = installFetch();
const view = mount(ticket);
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
expect(view.getByText(/Legacy assignment retained for audit only/)).toBeTruthy();
expect(view.getByText("Workspace agent: workspace-orchestrator")).toBeTruthy();
expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull();
expect(fetcher.mock.calls.some(([path]) => String(path).endsWith("/orchestrator"))).toBe(false);
expect(view.queryByLabelText("Principal kind")).toBeNull();
});
@@ -2,11 +2,14 @@
import { onDestroy, onMount } from "svelte";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
import { assignmentPrincipalLabel } from "./ticket-panel.ts";
import type { SubjektivSubjectResponse } from "#lib/generated/memory-api.ts";
import { parseSubjektivSubjectListResponse, MEMORY_API_LOAD_POLICY } from "#lib/workspace/memory/api.ts";
import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, formatDate } from "#lib/workspace/api/http.ts";
import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props();
let { workspaceId, ticket, refresh }: { workspaceId: string; ticket: TicketDetail; refresh: () => Promise<void> } = $props();
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
let busy = $state(false);
let pending = $state<ObservedOperation | null>(null);
@@ -16,15 +19,30 @@
let assignments = $state<TicketAssignmentsPage | null>(null);
let assignmentPageBusy = $state(false);
let answer = $state("");
let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent");
let accountId = $state("");
let runtimeId = $state("");
let workerId = $state("");
const principalReady = $derived(principalKind === "workspace_agent" || (principalKind === "user" ? !!accountId.trim() : !!runtimeId.trim() && !!workerId.trim()));
function selectedPrincipal(): TicketAssignmentPrincipalSnapshot {
if (principalKind === "user") return { kind: "user", account_id: accountId.trim() };
if (principalKind === "worker") return { kind: "worker", runtime_id: runtimeId.trim(), worker_id: workerId.trim() };
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
let subjectId = $state("");
let subjects = $state<SubjektivSubjectResponse[]>([]);
let subjectsBusy = $state(false);
let subjectsError = $state<string | null>(null);
let subjectsCursor = $state<string | null>(null);
const principalReady = $derived(subjects.some((subject) => subject.id === subjectId && subject.state === "active"));
function selectedPrincipal(): Extract<TicketAssignmentPrincipalSnapshot, { kind: "subject" }> | undefined {
return principalReady ? { kind: "subject", subject_id: subjectId } : undefined;
}
async function readSubjects(more = false) {
if (subjectsBusy) return;
subjectsBusy = true; subjectsError = null;
const query = new URLSearchParams({ limit: "100" });
if (more && subjectsCursor) query.set("cursor", subjectsCursor);
try {
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/subjektiv/subjects") + "?" + query, parseSubjektivSubjectListResponse, MEMORY_API_LOAD_POLICY.maxResponseBytes);
if (!alive) return;
subjects = more ? [...new Map([...subjects, ...page.items].map((subject) => [subject.id, subject])).values()] : page.items;
subjectsCursor = page.next_cursor ?? null;
// 再依頼の初期候補は観測済みの担当だけ。任意の先頭Subjectへは割り当てない。
const observedPrincipal = current?.principal;
if (!subjectId && observedPrincipal?.kind === "subject" && subjects.some((subject) => subject.id === observedPrincipal.subject_id && subject.state === "active")) subjectId = observedPrincipal.subject_id;
} catch (cause) { if (alive) subjectsError = operationError(cause); }
finally { subjectsBusy = false; }
}
let historyId = $state("");
let retained = $state<TicketAssignmentTrackingProjection | null>(null);
@@ -97,11 +115,12 @@
if (!current || pending || !answer.trim()) return;
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
}
onMount(() => { void readAssignments(); });
onMount(() => { void readAssignments(); if (ticket.action_eligibility.can_assign_orchestrator) void readSubjects(); });
</script>
<section class="ticket-detail-section ticket-handling">
<div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
<div class="ticket-section-heading"><h2>Subject handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
{#if current}
<p>{assignmentPrincipalLabel(current.principal)}</p>
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
{#if current.tracking}
<dl>
@@ -115,10 +134,9 @@
{#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if}
{#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if}
<details class="ticket-assignment-details"><summary>Assignment details</summary>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
</details>
{:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if}
{:else}<p class="workspace-empty-copy">No current Subject assignment.</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if savedNotice}<p role="status">{savedNotice}</p>{/if}
{#if pending}
@@ -128,26 +146,31 @@
{/if}
{#if ticket.state === "closed" && ticket.action_eligibility.can_assign_orchestrator}<p class="workspace-empty-copy">A delegation can be saved, but this closed case does not admit new work. Reopening the case is a separate decision and does not itself reissue handling.</p>{/if}
{#if ticket.action_eligibility.can_assign_orchestrator}
<details class="assignment-principal"><summary>Choose responsibility principal</summary>
<details class="assignment-principal"><summary>Choose responsible Subject</summary>
<fieldset class="ticket-control-form" disabled={busy || pending !== null}>
<label>Principal kind<select bind:value={principalKind}><option value="workspace_agent">Workspace Orchestrator agent</option><option value="user">User account</option><option value="worker">Worker principal</option></select></label>
{#if principalKind === "user"}<label>Account identity<input bind:value={accountId} maxlength="1024" /></label>{/if}
{#if principalKind === "worker"}<label>Runtime identity<input bind:value={runtimeId} maxlength="1024" /></label><label>Worker identity<input bind:value={workerId} maxlength="1024" /></label>{/if}
<label>Subject<select bind:value={subjectId} disabled={subjectsBusy}>
<option value="">Select a Subject…</option>
{#each subjects as subject (subject.id)}<option value={subject.id} disabled={subject.state !== "active"}>{subject.role} · {subject.id}{subject.state !== "active" ? " (retired)" : ""}</option>{/each}
</select></label>
</fieldset>
<p>Backend validates the selected principal. Responsibility does not choose or start a concrete Worker.</p>
{#if subjectsBusy}<p role="status">Loading Subjects…</p>{/if}
{#if subjectsError}<p class="workspace-callout is-error" role="alert">Subjects: {subjectsError}</p><button class="workspace-secondary-button" disabled={subjectsBusy} onclick={() => void readSubjects()}>Retry Subjects</button>{/if}
{#if subjectsCursor}<button class="workspace-secondary-button" disabled={subjectsBusy} onclick={() => void readSubjects(true)}>More Subjects</button>{/if}
{#if !subjectsBusy && !subjectsError && !subjects.length}<p>No Subjects available. Create or configure a Subject in <a href={`/w/${encodeURIComponent(workspaceId)}/memory`}>Memory</a>.</p>{/if}
<p>Backend validates the Subject and current permissions. Assignment does not grant permissions or select or start a concrete Worker.</p>
</details>
{/if}
<div class="handling-actions">
{#if ticket.action_eligibility.can_assign_orchestrator}
{#if !current || current.tracking?.status === "done"}
<button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Request Orchestrator"}</button>
<button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Assign Subject"}</button>
{:else}
<details><summary>Replace this delegation</summary><p>A replacement starts a new assignment even for the same principal. The previous result and release history remain.</p><button class="workspace-secondary-button" disabled={busy || pending !== null || !principalReady} onclick={() => { if (confirm("Replace this delegation with a new assignment to the selected principal?")) delegate(); }}>Replace assignment</button></details>
{/if}
{/if}
{#if ticket.action_eligibility.can_unassign_orchestrator}<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm("Release responsibility? This does not stop any running Worker.")) delegate(true); }}>Release assignment</button>{/if}
</div>
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done" && current.principal.kind === "subject"}
<form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}>
<label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
@@ -169,10 +192,15 @@
</details>
</section>
<BevelLine decorative />
<details class="ticket-detail-section ticket-orchestrator-worker">
<summary>Orchestrator Worker</summary>
{#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if}
</details>
{#if current?.principal.kind === "subject"}
<details class="ticket-detail-section ticket-subject-worker">
<summary>Subject and current Worker</summary>
<p>Assignment follows the Subject across Worker replacement. Worker availability does not change handling status.</p>
<a href={`/w/${encodeURIComponent(workspaceId)}/memory/${encodeURIComponent(current.principal.subject_id)}`}>Open responsible Subject and current Worker</a>
</details>
{:else if current}
<p class="workspace-callout">Legacy assignment retained for audit only. Select a Subject to create a new assignment; old receipts do not authorize new work.</p>
{/if}
<style>
.assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; }
dl { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
@@ -21,7 +21,7 @@ const assignment = (
ticket_id: "T-1",
assignment_id: id,
role: "orchestrator",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
principal: { kind: "subject", subject_id: "subject-a" },
assigned_by: "user",
assigned_at: "2026-01-01T00:00:00Z",
current: true,
@@ -51,6 +51,7 @@ function detail(row = assignment()): TicketDetail {
};
}
function reads(path: string, row: TicketAssignmentTrackingProjection) {
if (path.includes("/subjektiv/subjects")) return Response.json({ limit: 100, items: [{ id: "subject-a", role: "Research", state: "active", behavior_md: "", memory_fingerprint: "f", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }], has_more: false, next_cursor: null });
if (path.includes("/assignment-tracking/assignments")) {
return Response.json({ items: [row], next_assignment_id: null });
}
@@ -80,7 +81,6 @@ test("an answer response loss keeps exact assignment/event/key/body for retry ev
const view = render(TicketHandling, {
workspaceId: "team",
ticket: detail(row),
orchestrator: null,
refresh,
});
await fireEvent.input(
@@ -136,11 +136,13 @@ test("a done reissue persists before a failed refresh and does not pretend deliv
const view = render(TicketHandling, {
workspaceId: "team",
ticket: detail(row),
orchestrator: null,
refresh: vi.fn(async () => {
throw new Error("refresh unavailable");
}),
});
await fireEvent.click(view.getByText("Choose responsible Subject"));
await waitFor(() => expect(view.queryByText("Loading Subjects…")).toBeNull());
// Current Subject is the observed reissue candidate; different-Subject native selection is covered in Playwright.
await fireEvent.click(view.getByText("Request again (new assignment)"));
await waitFor(() =>
expect(view.getByText(/Assignment operation saved: delegation-2/))
@@ -64,7 +64,7 @@ Deno.test("generic Ticket launch carries natural text without role or resource c
Deno.test("case, stable tags and principal filters survive every server-query page", () => {
const filters = readTicketFilters(
new URLSearchParams(
"q=options&case=open&tag=tag-1&tag=tag-2&principal=orchestrator",
"q=options&case=open&tag=tag-1&tag=tag-2&principal=subject&subject=subject-a",
),
);
for (const cursor of [null, "next-page"]) {
@@ -76,8 +76,8 @@ Deno.test("case, stable tags and principal filters survive every server-query pa
states: ["open"],
tag_ids: ["tag-1", "tag-2"],
assigned_principal: {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
kind: "subject",
subject_id: "subject-a",
},
handling_status: null,
sort: "updated_desc",
@@ -196,3 +196,24 @@ Deno.test("ticket detail keeps the operation rail outside main content", async (
);
assertEquals(css.includes("min-width: 0;"), true);
});
Deno.test("Subject identity survives every query page without singleton normalization", () => {
for (const subject of ["subject-a", "subject-b"]) {
const filters = readTicketFilters(
new URLSearchParams({ principal: "subject", subject }),
);
for (const cursor of [null, "next"]) {
assertEquals(ticketQueryRequest(filters, [], cursor).assigned_principal, {
kind: "subject",
subject_id: subject,
});
}
}
const legacy = readTicketFilters(
new URLSearchParams("principal=orchestrator"),
);
assertEquals(ticketQueryRequest(legacy, []).assigned_principal, {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
});
@@ -1,4 +1,3 @@
import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
import type {
TicketAssignmentPrincipalSnapshot,
TicketAssignmentTrackingProjection,
@@ -11,7 +10,6 @@ import type {
export const TICKET_STATES = ["open", "closed"] as const;
export type TicketState = (typeof TICKET_STATES)[number];
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
export function ticketWorkerMessage(ticketId: string): string {
return `Work on Ticket ${ticketId}.`;
}
@@ -32,7 +30,8 @@ export function ticketWorkerLaunchHref(
}
export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const;
export const ORCHESTRATOR_PRINCIPAL = {
// 読取り専用の旧割当filter。新規割当へは使用しない。
export const LEGACY_ORCHESTRATOR_PRINCIPAL = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
} as const;
@@ -58,6 +57,7 @@ export function handlingLabel(
export function assignmentPrincipalLabel(
principal: TicketAssignmentPrincipalSnapshot,
): string {
if (principal.kind === "subject") return `Subject: ${principal.subject_id}`;
if (principal.kind === "workspace_agent") {
return `Workspace agent: ${principal.agent_key}`;
}
@@ -88,7 +88,8 @@ export type TicketFilters = {
query: string;
caseState: "all" | TicketState;
tagIds: string[];
principal: "all" | "orchestrator" | "user" | "worker";
principal: "all" | "subject" | "orchestrator" | "user" | "worker";
subjectId: string;
accountId: string;
runtimeId: string;
workerId: string;
@@ -109,10 +110,12 @@ export function readTicketFilters(search: URLSearchParams): TicketFilters {
query: search.get("q") ?? "",
caseState: state === "open" || state === "closed" ? state : "all",
tagIds: [...new Set(search.getAll("tag"))],
principal: principal === "orchestrator" || principal === "user" ||
principal: principal === "subject" || principal === "orchestrator" ||
principal === "user" ||
principal === "worker"
? principal
: "all",
subjectId: search.get("subject") ?? "",
accountId: search.get("account") ?? "",
runtimeId: search.get("runtime") ?? "",
workerId: search.get("worker") ?? "",
@@ -128,7 +131,15 @@ export function readTicketFilters(search: URLSearchParams): TicketFilters {
export function filterPrincipal(
filters: TicketFilters,
): TicketAssignmentPrincipalSnapshot | null {
if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL;
if (filters.principal === "subject") {
if (!filters.subjectId.trim()) {
throw new Error("Select a Subject for the assignment filter");
}
return { kind: "subject", subject_id: filters.subjectId.trim() };
}
if (filters.principal === "orchestrator") {
return LEGACY_ORCHESTRATOR_PRINCIPAL;
}
if (filters.principal === "user") {
if (!filters.accountId.trim()) {
throw new Error("Enter an account ID for the user principal filter");
@@ -327,3 +327,19 @@ Deno.test("mixed search compiles every supported filter and only remaining words
},
);
});
Deno.test("Subject search preserves exact identity, rejects blanks and conflicts", () => {
for (const subject of ["subject-a", "subject-b"]) {
assertEquals(
compileTicketSearch("assignee:subject:" + subject, tags)
.assigned_principal,
{ kind: "subject", subject_id: subject },
);
}
assertSearchError("assignee:subject:", "Subject identity");
assertSearchError('assignee:"subject: "', "Subject identity");
assertSearchError(
"assignee:subject:a assignee:subject:b",
"Conflicting assignee:",
);
});
@@ -1,7 +1,7 @@
import type {
TicketAssignmentPrincipalSnapshot,
TicketClosureReason,
TicketBrowseQueryRequest,
TicketClosureReason,
WorkspaceTag,
} from "../../generated/ticket-api.ts";
@@ -77,6 +77,13 @@ function tagId(value: string, tags: readonly WorkspaceTag[]): string {
}
function principal(value: string): TicketAssignmentPrincipalSnapshot {
if (value.startsWith("subject:")) {
const subject_id = value.slice("subject:".length);
if (!subject_id.trim()) {
throw new Error("Enter a Subject identity after subject:");
}
return { kind: "subject", subject_id };
}
if (value === "orchestrator") {
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
}
@@ -90,7 +97,7 @@ function principal(value: string): TicketAssignmentPrincipalSnapshot {
}
}
throw new Error(
"Invalid assignee: value; use orchestrator, user/<account-id>, or worker/<runtime-id>/<worker-id>.",
"Invalid assignee: value; use subject:<subject-id>. Audit-only legacy filters: orchestrator, user/<account-id>, or worker/<runtime-id>/<worker-id>.",
);
}
@@ -3,7 +3,6 @@ import {
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import {
HANDLING_STATES,
readTicketFilters,
@@ -17,7 +16,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
const workspaceId = params.workspaceId;
const filters = {
...readTicketFilters(url.searchParams),
principal: "orchestrator" as const,
tagIds: [],
closureReason: "all" as const,
closureText: "",
@@ -33,8 +32,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
null,
handling,
);
const [tickets, orchestrator] = await Promise.all([
loadJson(
const tickets = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
@@ -44,13 +42,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
return { workspaceId, filters, handling, tickets, orchestrator };
);
return { workspaceId, filters, handling, tickets };
});
@@ -3,7 +3,7 @@
import { untrack } from "svelte";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel, assignmentPrincipalLabel } from "#lib/workspace/tickets/ticket-panel.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
@@ -39,23 +39,25 @@
}
</script>
<svelte:head><title>Orchestrator · {data.workspaceId}</title></svelte:head>
<svelte:head><title>Subject handling · {data.workspaceId}</title></svelte:head>
<div class="workspace-page orchestrator-page">
<header class="workspace-page-header"><h1>Orchestrator</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}</p>
{#if data.orchestrator.error}<p class="workspace-callout is-error" role="alert">Worker availability: {data.orchestrator.error}</p>{/if}
<form class="orchestrator-filters" aria-label="Orchestrator filters" onsubmit={applyFilters}>
<header class="workspace-page-header"><h1>Subject handling</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
<p>Responsibility belongs to each Subject, independently of its current Worker.</p>
<form class="orchestrator-filters" aria-label="Subject handling filters" onsubmit={applyFilters}>
<input type="hidden" name="principal" value={data.filters.subjectId ? "subject" : "all"} />
<label>Subject identity<input name="subject" value={data.filters.subjectId} placeholder="All Subjects" oninput={(event) => { const input = event.currentTarget.form?.elements.namedItem("principal") as HTMLInputElement | null; if (input) input.value = event.currentTarget.value.trim() ? "subject" : "all"; }} /></label>
<label>Search<input name="q" type="search" value={data.filters.query} /></label>
<label>Handling (self-reported)<select name="handling" value={data.handling ?? ""}><option value="">All including unknown</option><option value="waiting">Waiting</option><option value="inprogress">In progress</option><option value="done">Done</option></select></label>
<label>Ticket case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
<button class="workspace-secondary-button" disabled={busy}>{busy ? "Loading…" : "Apply filters"}</button>
</form>
<section class="orchestrator-assignments" aria-label="Current Orchestrator assignments">
<section class="orchestrator-assignments" aria-label="Current Subject assignments">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const row = currentOrchestratorAssignment(ticket)}
<a class="orchestrator-assignment" href={ticketHref(data.workspaceId, ticket)}>
<div class="assignment-heading"><span>{ticket.resource_key}</span><strong>{ticket.title}</strong></div>
<div class="assignment-meta"><span>Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})</span><span>Ticket case: {ticket.state}</span></div>
{#if row}<p>{assignmentPrincipalLabel(row.principal)}</p>{/if}
<p>{handlingSummary(row)}</p>
{#if row?.tracking?.next_action}<p>Next action: {row.tracking.next_action}</p>{/if}
{#if ticket.current_closure}<p>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
@@ -9,7 +9,6 @@
workspaceApiJsonWithBody,
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 {
@@ -27,7 +26,6 @@
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageData as RouteData } from "./$types";
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
@@ -56,9 +54,6 @@
searchBusy = false;
pageBusy = false;
navigationError = null;
orchestrator = { data: null, error: null };
orchestratorLoading = true;
orchestratorStarting = false;
repositoryResponse = { data: null, error: null };
repositoriesLoading = false;
creatingTicket = false;
@@ -67,13 +62,9 @@
createTitle = "";
createBody = "";
createTargets = [];
void loadOrchestrator(currentData, generation);
});
return () => { ++routeGeneration; };
});
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);
let createError = $state<string | null>(null);
@@ -100,16 +91,6 @@
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;
@@ -242,22 +223,6 @@
}
}
async function startOrchestrator() {
if (orchestratorLoading || orchestratorStarting || orchestrator.data?.online) return;
const observedData = data;
const generation = routeGeneration;
orchestratorStarting = true;
const result = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(observedData.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorStarting = false;
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
@@ -280,23 +245,7 @@
type="button"
onclick={toggleCreate}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online} aria-busy={orchestratorLoading}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestratorLoading ? "Checking…" : orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
class="workspace-secondary-button"
type="button"
disabled={orchestratorStarting}
onclick={startOrchestrator}
>
{orchestratorStarting ? "Starting…" : "Start Orchestrator"}
</button>
{/if}
</div>
</div>
</header>
@@ -347,11 +296,6 @@
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{/if}
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
@@ -364,11 +308,11 @@
<details class="ticket-search-help">
<summary>Search syntax</summary>
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
<p><code>tag:"UI bug"</code> · <code>assignee:subject:SUBJECT_ID</code> · <code>reason:completed</code></p>
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
</details>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Subject handling</a>
</div>
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
@@ -2,7 +2,6 @@ import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { error, redirect } from "@sveltejs/kit";
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 {
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
@@ -11,7 +10,6 @@ import {
canonicalResourceReference,
resourceKey,
} from "#lib/workspace/resource-links.ts";
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
@@ -22,7 +20,7 @@ export const load = deferPage(
params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
const [ticket, repositoriesRaw] = await Promise.all([
loadJson(
fetch,
ticketPath,
@@ -31,12 +29,6 @@ export const load = deferPage(
TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (!ticket.data) {
const message = ticket.error ?? "Ticket details could not be loaded";
@@ -70,7 +62,6 @@ export const load = deferPage(
ticketId: ticket.data?.id ?? reference,
ticket,
repositories,
orchestrator,
};
},
);
@@ -24,6 +24,7 @@
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import {
relationLabel,
assignmentPrincipalLabel,
ticketWorkerLaunchHref,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type {
@@ -582,7 +583,7 @@
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<BevelLine decorative />
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
{/key}
@@ -696,15 +697,7 @@
{#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>
<span>{assignmentPrincipalLabel(assignment.principal)}</span>
</li>
{/each}
</ul>
@@ -73,13 +73,14 @@ test("canonical initial post and discussion share markup and editing while all m
.toEqual(["ticket-thread-event", "divider", "ticket-thread-event", "divider", "ticket-thread-event"]);
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.form.firstElementChild?.matches(".bevel-line")).toBe(true);
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(main.querySelector(selector)).toBeNull();
expect(rail.querySelector(selector)).not.toBeNull();
}
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(".ticket-orchestrator-worker")).toBeNull();
for (const selector of [".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(selector)?.hasAttribute("open")).toBe(false);
}
expect(within(rail).getByText("Created by")).toBeTruthy();
@@ -132,7 +132,7 @@ afterEach(() => {
vi.clearAllMocks();
});
test("actual loader posts browse only; rows render while orchestrator is unresolved and repositories stay lazy", async () => {
test("actual loader posts browse only; no singleton status request or startup UI and repositories stay lazy", async () => {
const rows = deferred<Response>();
const orchestrator = deferred<Response>();
const repos = deferred<Response>();
@@ -151,19 +151,9 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
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",
]);
expect(view.container.querySelector(".orchestrator-status")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" })).toBeNull();
expect(request.mock.calls.map(([path]) => path)).toEqual(["/api/w/workspace-a/tickets/browse"]);
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(
request.mock.calls.filter(([path]) =>
@@ -174,7 +164,7 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
expect(screen.getByText("First ticket")).toBeTruthy();
repos.resolve(Response.json(repositories()));
orchestrator.resolve(Response.json(status()));
expect(await screen.findByText("Online")).toBeTruthy();
expect(screen.queryByText("Online")).toBeNull();
});
test("lazy repository HTTP failure is visible and retry enables targetless creation without reloading tickets", async () => {
@@ -224,19 +214,13 @@ test("lazy repository HTTP failure is visible and retry enables targetless creat
.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 });
test("no singleton status is read even if the compatibility endpoint would fail", async () => {
const request = requests({ "/api/w/workspace-a/orchestrator": async () => { throw new Error("No singleton"); } });
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();
await flushResponses();
expect(request).not.toHaveBeenCalled();
expect(screen.getByText("First ticket")).toBeTruthy();
expect(screen.queryByText("Checking…")).toBeNull();
});
test("late prior-Workspace status and repository responses cannot replace current state or finish its pending load", async () => {
@@ -267,7 +251,7 @@ test("late prior-Workspace status and repository responses cannot replace curren
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("Checking…")).toBeNull();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(
@@ -277,7 +261,7 @@ test("late prior-Workspace status and repository responses cannot replace curren
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();
expect(screen.queryByText("Online")).toBeNull();
await waitFor(() =>
expect(
(screen.getByRole("button", {
@@ -29,7 +29,9 @@ Deno.test("ticket child operations reset across Workspaces even when the retaine
const keyedChildren = [...pageSource.matchAll(
/\{#key JSON\.stringify\(\[data\.workspaceId, ticket\.id\]\)\}([\s\S]*?)\{\/key\}/g,
)].map((match) => match[1]);
for (const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]) {
for (
const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]
) {
assert(
keyedChildren.some((block) => block.includes(`<${child}`)),
`${child} must reset for the Workspace and Ticket, whether in main or rail`,
@@ -63,8 +65,20 @@ Deno.test("ticket detail fences stale mutation responses", () => {
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
assertStringIncludes(pageSource, "Role assignments and manual Workers");
assertStringIncludes(pageSource, "Assignments are retained independently of Worker state.");
assertStringIncludes(pageSource, "assignment.principal.runtime_id");
assertStringIncludes(pageSource, "assignment.principal.worker_id");
assertStringIncludes(
pageSource,
"Assignments are retained independently of Worker state.",
);
assertStringIncludes(
pageSource,
"assignmentPrincipalLabel(assignment.principal)",
);
const modelSource = Deno.readTextFileSync(
new URL("../src/lib/workspace/tickets/ticket-panel.ts", import.meta.url),
);
assertStringIncludes(modelSource, 'principal.kind === "subject"');
assertStringIncludes(modelSource, "principal.subject_id");
assertStringIncludes(modelSource, "principal.runtime_id");
assertStringIncludes(modelSource, "principal.worker_id");
assert(!pageSource.includes("No active role assignment."));
});
@@ -0,0 +1,143 @@
{
"schemaVersion": 1,
"id": "subject-handling",
"title": "Ticket Subject responsibility, separate from Worker execution",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "member",
"label": "Synthetic authenticated member, not settings owner",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "principal",
"label": "Choose responsibility",
"path": "/w/tickets/tickets/T-2",
"goal": "Inspect handling and choose responsible Subject",
"dataState": "Open and waiting with current assignment, no current body Worker",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "choice",
"label": "Observed assignment and choice",
"interaction": [
{
"action": "click",
"selector": ".assignment-principal summary"
}
],
"fullPage": true
}
]
},
{
"id": "legacy",
"path": "/w/tickets-legacy/tickets/T-2",
"goal": "Inspect retained legacy assignment audit",
"dataState": "Legacy assignment; requires explicit Subject reissue",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "audit",
"label": "Legacy audit and no implicit startup",
"fullPage": true
}
],
"label": "Inspect retained legacy assignment audit"
},
{
"id": "handling",
"path": "/w/tickets/orchestrator",
"goal": "Inspect handling across Subjects",
"dataState": "Multiple Ticket cases and self-reported handling",
"ready": {
"kind": "selector",
"selector": ".orchestrator-assignments"
},
"capturePoints": [
{
"id": "list",
"label": "Responsibility list",
"fullPage": true
}
],
"label": "Inspect handling across Subjects"
},
{
"id": "cases",
"path": "/w/tickets/tickets",
"goal": "Find Ticket cases without singleton startup",
"dataState": "Ticket cases including open and closed",
"ready": {
"kind": "selector",
"selector": ".ticket-panel-page"
},
"capturePoints": [
{
"id": "list",
"label": "Ticket case list",
"fullPage": true
}
],
"label": "Find Ticket cases without singleton startup"
}
],
"processes": [
{
"id": "ticket-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/ticket_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "subject-fixture-proxy",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/subject-handling-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
@@ -0,0 +1,156 @@
// 共有Ticket fixtureの古い認証・担当projectionだけをAPI境界で更新する。
// 製品shell/CSS・認可処理には手を加えず、実Backend/Worker実行の証拠にはしない。
const port = Number(Deno.args[0]);
const upstream = Deno.args[1];
const baseline = Deno.args.includes("--baseline");
if (!Number.isInteger(port) || !upstream) {
throw new Error("usage: server <port> <upstream> [--baseline]");
}
const subject = (id: string, state = "active") => ({
id,
role: "Research",
state,
behavior_md: "",
memory_fingerprint: "fixture",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
current_worker: null,
});
function project(value: unknown): unknown {
if (Array.isArray(value)) return value.map(project);
if (value && typeof value === "object") {
const item = value as Record<string, unknown>;
if (
item.kind === "workspace_agent" &&
item.agent_key === "workspace-orchestrator"
) return { kind: "subject", subject_id: "subject-a" };
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, project(value)]),
);
}
return value;
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (
url.pathname.endsWith("/protocol/ws") &&
request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method === "subscribe_events") {
socket.send(JSON.stringify({
protocol_version: 2,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: frame.message.params.request_id,
subscription_id: "fixture-workers",
selector: frame.message.params.selector,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
}
};
return response;
}
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "fixture-user",
account_id: "fixture-member",
handle: "mika",
display_name: "Mika",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: false });
}
if (url.pathname.endsWith("/subjektiv/subjects")) {
if (url.pathname.includes("tickets-denied")) {
return Response.json({ message: "Subject listing denied" }, {
status: 403,
});
}
if (url.pathname.includes("tickets-empty")) {
return Response.json({
limit: 100,
items: [],
has_more: false,
next_cursor: null,
});
}
const more = url.searchParams.has("cursor");
return Response.json({
limit: 100,
items: more
? [
subject("subject-a"),
subject("subject-b"),
subject("subject-retired", "retired"),
]
: [subject("subject-a")],
has_more: !more,
next_cursor: more ? null : "next-subject",
});
}
if (url.pathname.endsWith("/tickets/browse")) {
const input = await request.json();
const queryUrl = new URL(
url.pathname.replace(/\/browse$/, "/query"),
upstream,
);
const response = await fetch(queryUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) return response;
const data = await response.json();
data.items = data.items.map((item: Record<string, unknown>) =>
Object.fromEntries(
[
"id",
"resource_key",
"title",
"state",
"updated_at",
"tags",
"current_closure",
"assignment_tracking",
].map((key) => [key, item[key]]),
)
);
return Response.json(baseline ? data : project(data));
}
const response = await fetch(new URL(url.pathname + url.search, upstream), {
method: request.method,
headers: request.headers,
body: request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer(),
redirect: "manual",
});
if (url.pathname.endsWith("/workspace") && response.ok) {
const value = await response.json();
value.permissions.delete_subjects = false;
return Response.json(value);
}
if (
!baseline && !url.pathname.includes("tickets-legacy") &&
/(?:\/api\/w|\/workspaces)\/[^/]+\/tickets/.test(url.pathname) &&
response.headers.get("content-type")?.includes("application/json")
) {
return Response.json(project(await response.json()), {
status: response.status,
});
}
return response;
});
@@ -0,0 +1,143 @@
{
"schemaVersion": 1,
"id": "subject-handling",
"title": "Ticket Subject responsibility, separate from Worker execution",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "member",
"label": "Synthetic authenticated member, not settings owner",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "principal",
"label": "Choose responsibility",
"path": "/w/tickets/tickets/T-2",
"goal": "Inspect handling and choose responsible Subject",
"dataState": "Open and waiting with current assignment, no current body Worker",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "choice",
"label": "Observed assignment and choice",
"interaction": [
{
"action": "click",
"selector": ".assignment-principal summary"
}
],
"fullPage": true
}
]
},
{
"id": "legacy",
"path": "/w/tickets-legacy/tickets/T-2",
"goal": "Inspect retained legacy assignment audit",
"dataState": "Legacy assignment; requires explicit Subject reissue",
"ready": {
"kind": "selector",
"selector": ".ticket-handling"
},
"capturePoints": [
{
"id": "audit",
"label": "Legacy audit and no implicit startup",
"fullPage": true
}
],
"label": "Inspect retained legacy assignment audit"
},
{
"id": "handling",
"path": "/w/tickets/orchestrator",
"goal": "Inspect handling across Subjects",
"dataState": "Multiple Ticket cases and self-reported handling",
"ready": {
"kind": "selector",
"selector": ".orchestrator-assignments"
},
"capturePoints": [
{
"id": "list",
"label": "Responsibility list",
"fullPage": true
}
],
"label": "Inspect handling across Subjects"
},
{
"id": "cases",
"path": "/w/tickets/tickets",
"goal": "Find Ticket cases without singleton startup",
"dataState": "Ticket cases including open and closed",
"ready": {
"kind": "selector",
"selector": ".ticket-panel-page"
},
"capturePoints": [
{
"id": "list",
"label": "Ticket case list",
"fullPage": true
}
],
"label": "Find Ticket cases without singleton startup"
}
],
"processes": [
{
"id": "ticket-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/ticket_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "subject-fixture-proxy",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/subject-handling-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
@@ -0,0 +1,289 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { chromium } from "npm:playwright@1.59.1";
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
// 実production buildのnative selectとloopback fixtureだけを検証する。
// 実Backend認可、Worker起動、通知配送の成功とは区別する。
Deno.test("Subject selection, paging, audit, permission failure and exact retry without singleton startup", async () => {
const scenarioPath =
new URL("./subject-handling-light.json", import.meta.url).pathname;
const scenario = await loadScenario(scenarioPath);
function freePort() {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
const upstream = freePort();
let proxy = freePort();
while (upstream === proxy) proxy = freePort();
scenario.processes![0].args![4] = String(upstream);
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstream}/health`;
scenario.processes![1].args![3] = String(proxy);
scenario.processes![1].args![4] = `http://127.0.0.1:${upstream}`;
scenario.processes![1].readyUrl = `http://127.0.0.1:${proxy}/health`;
const base = `http://127.0.0.1:${proxy}`;
const output = new URL(
"../../../../target/web-ux/t778-validation/browser/",
import.meta.url,
).pathname;
await Deno.mkdir(output, { recursive: true });
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
output + "/process-logs",
[],
);
const browser = await chromium.launch({ headless: true });
const captures: unknown[] = [];
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
let documentStatus: number | null = null;
page.on("response", (response) => {
if (response.request().isNavigationRequest()) {
documentStatus = response.status();
}
});
const errors: string[] = [];
const pageErrors: string[] = [];
const requestFailures: string[] = [];
page.on("requestfailed", (request) => {
requestFailures.push(
`${request.method()} ${
new URL(request.url()).pathname
}: ${request.failure()?.errorText}`,
);
});
async function captureState(state: string, expectedFailure = false) {
assertEquals(pageErrors, []);
const image = `${colorScheme}-${width}-${state}.png`;
const bytes = await page.screenshot({
path: output + image,
fullPage: true,
});
const sha256 = Array.from(
new Uint8Array(
await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)),
),
).map((byte) => byte.toString(16).padStart(2, "0")).join("");
captures.push({
image,
sha256,
route: page.url(),
documentStatus,
colorScheme,
width,
expectedFailure,
errors: [...errors],
pageErrors: [...pageErrors],
requestFailures: [...requestFailures],
horizontalOverflow: await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
snapshot: await page.locator("main").first().ariaSnapshot(),
});
}
const paths: string[] = [];
page.on("pageerror", (error) => pageErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("request", (request) => {
paths.push(new URL(request.url()).pathname);
});
await page.goto(base + "/w/tickets/tickets/T-2");
await page.getByText("Self-reported handling: waiting", {
exact: true,
}).waitFor();
await page.getByText("Choose responsible Subject", { exact: true })
.click();
const select = page.getByRole("combobox", {
name: "Subject",
exact: true,
});
await select.selectOption("subject-a");
await page.getByRole("button", { name: "More Subjects", exact: true })
.click();
await page.locator('option[value="subject-b"]').waitFor({
state: "attached",
});
assertEquals(
await select.locator('option[value="subject-a"]').count(),
1,
);
assert(
await select.locator('option[value="subject-retired"]').evaluate((
option,
) => (option as unknown as { disabled: boolean }).disabled),
await select.innerHTML(),
);
// Native select remains keyboard operable after paging.
await select.focus();
await select.press("Home");
await select.press("ArrowDown");
assertEquals(await select.inputValue(), "subject-a");
await select.press("ArrowDown");
assertEquals(await select.inputValue(), "subject-b");
assert(
await page.evaluate("document.activeElement?.tagName === 'SELECT'"),
);
await select.scrollIntoViewIfNeeded();
await captureState("selection");
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
);
assertEquals(errors, []);
assertEquals(requestFailures, []);
const sent: string[] = [];
await page.route("**/assignments/orchestrator", async (route) => {
if (route.request().method() === "PUT") {
sent.push(route.request().postData()!);
await route.abort("failed");
} else await route.continue();
});
await page.getByText("Replace this delegation", { exact: true })
.click();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
.waitFor();
assertEquals(sent.length, 1);
const input = JSON.parse(sent[0]);
assertEquals(input.principal, {
kind: "subject",
subject_id: "subject-b",
});
assertEquals(input.expected_assignment_id, "assignment-T-2");
assert(await select.isDisabled());
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
.scrollIntoViewIfNeeded();
await captureState("unknown", true);
assertEquals(errors, ["Failed to load resource: net::ERR_FAILED"]);
assertEquals(requestFailures.length, 1);
assert(requestFailures[0].startsWith("PUT /api/w/tickets/tickets/"));
assert(
requestFailures[0].endsWith(
"/assignments/orchestrator: net::ERR_FAILED",
),
);
const failed = page.waitForEvent(
"requestfailed",
(r) =>
r.method() === "PUT" &&
r.url().endsWith("/assignments/orchestrator"),
);
await page.getByRole("button", {
name: "Retry exact operation",
exact: true,
}).click();
await failed;
assertEquals(sent.length, 2);
assertEquals(sent[1], sent[0]);
assertEquals(
paths.filter((path) => /\/api\/w\/[^/]+\/orchestrator/.test(path)),
[],
);
await page.unroute("**/assignments/orchestrator");
for (const persona of ["legacy", "readonly", "empty", "denied"]) {
errors.length = 0;
requestFailures.length = 0;
await page.goto(`${base}/w/tickets-${persona}/tickets/T-2`);
await page.locator(".ticket-handling").waitFor();
if (persona === "legacy") {
await page.getByText(/Legacy assignment retained for audit only/)
.scrollIntoViewIfNeeded();
assertEquals(
await page.getByRole("textbox", {
name: "Answer / continue this handling",
}).count(),
0,
);
} else if (persona === "readonly") {
assertEquals(
await page.getByText("Choose responsible Subject", {
exact: true,
}).count(),
0,
);
} else {
await page.getByText("Choose responsible Subject", {
exact: true,
}).click();
if (persona === "empty") {
await page.getByText(/No Subjects available/).waitFor();
} else {await page.getByRole("button", { name: "Retry Subjects" })
.waitFor();}
await page.getByText("Replace this delegation", { exact: true })
.click();
assert(
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).isDisabled(),
);
await page.getByRole("button", {
name: "Replace assignment",
exact: true,
}).scrollIntoViewIfNeeded();
}
if (persona === "readonly") {
await page.locator(".ticket-handling").scrollIntoViewIfNeeded();
}
await captureState(persona, persona === "denied");
assertEquals(
await page.evaluate(
"document.documentElement.scrollWidth > innerWidth",
),
false,
);
if (persona !== "denied") {
assertEquals(errors, []);
assertEquals(requestFailures, []);
} else {
assertEquals(errors, [
"Failed to load resource: the server responded with a status of 403 (Forbidden)",
]);
assertEquals(requestFailures, [
"GET /api/w/tickets-denied/subjektiv/subjects: net::ERR_ABORTED",
]);
}
}
} finally {
await context.close();
}
}
}
} finally {
await Deno.writeTextFile(
output + "/review-context.json",
JSON.stringify(
{
source: "production Web build, synthetic API boundary only",
captures,
},
null,
2,
),
);
await browser.close();
await stopOwnedProcesses(processes);
}
});