feat: connect Subject Ticket assignments across public execution paths
This commit is contained in:
@@ -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>
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user