feat: compose Subject Ticket assignment with selected dependency contracts
Adapt T-778 source6d15569and selected T-465 plugin plans on T-80351f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
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:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// 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:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// 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:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// 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:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// 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-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -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:812a8f0a3b60df97ffe16e4905915b5c8d31b6795b8cd535e2adc10ed19078ca)
|
||||
// Input contract: 0.1.0 (sha256:ff2fc2c7f04f17acb1aa95bbeb3908c37bb93c32d2537e1c2a31ddb5182f5ee3)
|
||||
// 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 = {
|
||||
|
||||
@@ -316,6 +316,21 @@ export async function fetchConfigHistory(
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WorkspaceConfigTreeResponse> {
|
||||
return await readJson(
|
||||
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitConfigTree(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
|
||||
+12
@@ -7,6 +7,12 @@ export function apply_changes(changes: any): any;
|
||||
|
||||
export function changes_between(base: any, candidate: any): any;
|
||||
|
||||
/**
|
||||
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
|
||||
*
|
||||
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
|
||||
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
|
||||
*/
|
||||
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
||||
|
||||
export function compose_schema_bundle(contributions: any): any;
|
||||
@@ -21,6 +27,12 @@ export function format_source(source: string): string;
|
||||
|
||||
export function set_schema_bundle(schema_bundle: any): void;
|
||||
|
||||
/**
|
||||
* Retain the browser's edit candidate as a snapshot.
|
||||
*
|
||||
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
|
||||
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
|
||||
*/
|
||||
export function set_snapshot(snapshot: any): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
@@ -44,6 +44,10 @@ export function changes_between(base, candidate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
|
||||
*
|
||||
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
|
||||
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
|
||||
* @param {string} entrypoint
|
||||
* @param {string} source
|
||||
* @param {number} utf16_offset
|
||||
@@ -154,6 +158,10 @@ export function set_schema_bundle(schema_bundle) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain the browser's edit candidate as a snapshot.
|
||||
*
|
||||
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
|
||||
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
|
||||
* @param {any} snapshot
|
||||
*/
|
||||
export function set_snapshot(snapshot) {
|
||||
|
||||
Binary file not shown.
@@ -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>.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,40 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { WorkspaceResponse } from "#lib/generated/legacy-server-api.ts";
|
||||
|
||||
// SubjectPage inherits this validated projection from the Workspace layout.
|
||||
// No deletion grant is implied by these behavior/Memory read fixtures.
|
||||
function memoryWorkspace(workspaceId = "workspace-1"): WorkspaceResponse {
|
||||
const extension = { status: "ready", note: "fixture", diagnostics: [] };
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
display_name: "Memory fixture",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: "http://127.0.0.1",
|
||||
public_base_url: "http://127.0.0.1",
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: false,
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
delete_subjects: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: extension,
|
||||
host_worker_bridge: extension,
|
||||
companion_console: extension,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { gotoMock } = vi.hoisted(() => ({
|
||||
gotoMock: vi.fn(async () => {}),
|
||||
@@ -240,6 +274,7 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
cursor: "memory-current",
|
||||
subject: result({
|
||||
@@ -334,6 +369,7 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
surface: result({
|
||||
@@ -378,15 +414,34 @@ test("edits and clears user-managed behavior with CAS while distinguishing save
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
const secondBody =
|
||||
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
|
||||
expect(secondBody).toBe('{"expected_behavior_md":"Ask before irreversible actions.","behavior_md":""}');
|
||||
expect(secondBody).toBe(
|
||||
'{"expected_behavior_md":"Ask before irreversible actions.","behavior_md":""}',
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No behavior is set.")).not.toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"Subject deletion visibility follows the inherited explicit grant: %s",
|
||||
(allowed) => {
|
||||
const data = subjectPageData();
|
||||
data.workspace.permissions.delete_subjects = allowed;
|
||||
render(SubjectPage, { data } as never);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Delete Subject" }) !==
|
||||
null,
|
||||
).toBe(allowed);
|
||||
// Behavior editability is not deletion authority.
|
||||
expect(screen.getByRole("button", { name: "Edit" })).not
|
||||
.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
|
||||
return {
|
||||
workspaceId,
|
||||
workspace: memoryWorkspace(workspaceId),
|
||||
subjectId: record.id,
|
||||
subject: result(record),
|
||||
surface: result({
|
||||
@@ -405,12 +460,20 @@ test("same-identity refresh updates Subject projection without rebasing an activ
|
||||
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, { target: { value: "Unsaved behavior draft" } });
|
||||
await fireEvent.input(textarea, {
|
||||
target: { value: "Unsaved behavior draft" },
|
||||
});
|
||||
|
||||
const refreshed = { ...subject(), role: "Refreshed role", behavior_md: "Changed on server." };
|
||||
const refreshed = {
|
||||
...subject(),
|
||||
role: "Refreshed role",
|
||||
behavior_md: "Changed on server.",
|
||||
};
|
||||
await view.rerender({ data: subjectPageData(refreshed) } as never);
|
||||
expect(screen.getByRole("heading", { name: "Refreshed role", level: 1 })).not.toBeNull();
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Unsaved behavior draft");
|
||||
expect(screen.getByRole("heading", { name: "Refreshed role", level: 1 })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" }))
|
||||
.toHaveProperty("value", "Unsaved behavior draft");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull());
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
@@ -418,36 +481,71 @@ test("same-identity refresh updates Subject projection without rebasing an activ
|
||||
behavior_md: "Unsaved behavior draft",
|
||||
});
|
||||
|
||||
await view.rerender({ data: subjectPageData({ ...refreshed, behavior_md: "Changed again." }) } as never);
|
||||
await view.rerender(
|
||||
{
|
||||
data: subjectPageData({ ...refreshed, behavior_md: "Changed again." }),
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert")).not.toBeNull();
|
||||
expect(textarea).toHaveProperty("value", "Unsaved behavior draft");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).expected_behavior_md).toBe("Prefer explicit evidence.");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty("disabled", false));
|
||||
expect(
|
||||
JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).expected_behavior_md,
|
||||
).toBe("Prefer explicit evidence.");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toHaveProperty(
|
||||
"disabled",
|
||||
false,
|
||||
)
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Changed again.");
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
).toContain("Changed again.");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Changed again.");
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" }))
|
||||
.toHaveProperty("value", "Changed again.");
|
||||
});
|
||||
|
||||
test("same-identity refresh keeps an in-flight behavior save fenced and accepts its result", async () => {
|
||||
let resolveRequest!: (response: Response) => void;
|
||||
const fetchMock = vi.fn(() => new Promise<Response>((resolve) => { resolveRequest = resolve; }));
|
||||
const fetchMock = vi.fn(() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const view = render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: "Subject behavior" }), {
|
||||
target: { value: "Saved draft" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Subject behavior" }),
|
||||
{
|
||||
target: { value: "Saved draft" },
|
||||
},
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await view.rerender({ data: subjectPageData({ ...subject(), behavior_md: "Refreshed text" }) } as never);
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" })).toHaveProperty("value", "Saved draft");
|
||||
await view.rerender(
|
||||
{
|
||||
data: subjectPageData({ ...subject(), behavior_md: "Refreshed text" }),
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Saving…" })).toHaveProperty(
|
||||
"disabled",
|
||||
true,
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Subject behavior" }))
|
||||
.toHaveProperty("value", "Saved draft");
|
||||
resolveRequest(Response.json({ ...subject(), behavior_md: "Saved draft" }));
|
||||
await waitFor(() => expect(screen.getByText(/storage confirmed/)).not.toBeNull());
|
||||
expect(screen.getByRole("article", { name: "User-managed Subject behavior" }).textContent).toContain("Saved draft");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/storage confirmed/)).not.toBeNull()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("article", { name: "User-managed Subject behavior" })
|
||||
.textContent,
|
||||
).toContain("Saved draft");
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -635,27 +733,39 @@ test("a refreshed loader snapshot fences old saves even after returning to the s
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test.each([409, 503])("behavior save rejection %s preserves the draft and observed CAS text", async (status) => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ message: "save rejected" }, { status }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, { target: { value: " Unsaved\nbehavior.\n" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull());
|
||||
expect(textarea).toHaveProperty("value", " Unsaved\nbehavior.\n");
|
||||
expect(screen.queryByText(/storage confirmed/)).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
"/api/w/workspace-1/subjektiv/subjects/subject-1/behavior",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ expected_behavior_md: "Prefer explicit evidence.", behavior_md: " Unsaved\nbehavior.\n" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
test.each([409, 503])(
|
||||
"behavior save rejection %s preserves the draft and observed CAS text",
|
||||
async (status) => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
Response.json({ message: "save rejected" }, { status })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(SubjectPage, { data: subjectPageData() } as never);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
|
||||
await fireEvent.input(textarea, {
|
||||
target: { value: " Unsaved\nbehavior.\n" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save behavior" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBeNull());
|
||||
expect(textarea).toHaveProperty("value", " Unsaved\nbehavior.\n");
|
||||
expect(screen.queryByText(/storage confirmed/)).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
"/api/w/workspace-1/subjektiv/subjects/subject-1/behavior",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_behavior_md: "Prefer explicit evidence.",
|
||||
behavior_md: " Unsaved\nbehavior.\n",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
|
||||
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
|
||||
@@ -664,6 +774,7 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
cursor: null,
|
||||
subject: result({ ...subject(), memory_fingerprint: "fingerprint-0" }),
|
||||
@@ -747,6 +858,7 @@ test.each(["ready", "ungenerated", "stale", "failed"] as const)(
|
||||
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
|
||||
const base = {
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
subject: result(subject()),
|
||||
memories: result({ items: [], has_more: false }),
|
||||
@@ -832,6 +944,7 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha
|
||||
render(MemoryDetailPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
workspace: memoryWorkspace(),
|
||||
subjectId: "subject-1",
|
||||
memoryId: "memory-1",
|
||||
changeCursor: "a/current",
|
||||
@@ -872,7 +985,10 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha
|
||||
source_refs_total: 1,
|
||||
source_refs_truncated: false,
|
||||
}],
|
||||
derived_from: [{ memory_id: "memory-parent", change_id: "parent/source & context" }],
|
||||
derived_from: [{
|
||||
memory_id: "memory-parent",
|
||||
change_id: "parent/source & context",
|
||||
}],
|
||||
evidence_has_more: true,
|
||||
evidence_next_cursor: "evidence/next",
|
||||
}),
|
||||
@@ -912,23 +1028,39 @@ test("renders committed Memory detail, provenance, derivation, and immutable cha
|
||||
expect(screen.getByText("Historical change")).not.toBeNull();
|
||||
expect(screen.getByRole("navigation", { name: "Change history pages" }))
|
||||
.not.toBeNull();
|
||||
const historicalLink = screen.getByRole("link", { name: /Memory change z\/earlier/ });
|
||||
const historicalLink = screen.getByRole("link", {
|
||||
name: /Memory change z\/earlier/,
|
||||
});
|
||||
expect(historicalLink.getAttribute("aria-current")).toBe("page");
|
||||
expect(historicalLink.getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-1?change_id=z%2Fearlier%20%26%20context");
|
||||
const currentLink = screen.getByRole("link", { name: /Memory change a\/current/ });
|
||||
expect(historicalLink.getAttribute("href")).toBe(
|
||||
"/w/workspace-1/memory/subject-1/memory-1?change_id=z%2Fearlier%20%26%20context",
|
||||
);
|
||||
const currentLink = screen.getByRole("link", {
|
||||
name: /Memory change a\/current/,
|
||||
});
|
||||
expect(currentLink.getAttribute("aria-current")).toBeNull();
|
||||
expect(currentLink.textContent).toContain("Current");
|
||||
expect(screen.getByRole("link", { name: /memory-parent/ }).getAttribute("href")).toBe("/w/workspace-1/memory/subject-1/memory-parent?change_id=parent%2Fsource%20%26%20context");
|
||||
const bodyHref = screen.getByRole("link", { name: "View next body segment" }).getAttribute("href");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /memory-parent/ }).getAttribute("href"),
|
||||
).toBe(
|
||||
"/w/workspace-1/memory/subject-1/memory-parent?change_id=parent%2Fsource%20%26%20context",
|
||||
);
|
||||
const bodyHref = screen.getByRole("link", { name: "View next body segment" })
|
||||
.getAttribute("href");
|
||||
const bodyQuery = new URL(bodyHref!, "https://example.com").searchParams;
|
||||
expect(bodyQuery.get("change_id")).toBe("z/earlier & context");
|
||||
expect(bodyQuery.get("offset")).toBe("2");
|
||||
expect(bodyQuery.get("byte_offset")).toBe("0");
|
||||
const evidenceHref = screen.getByRole("link", { name: "View next evidence page" }).getAttribute("href");
|
||||
const evidenceQuery = new URL(evidenceHref!, "https://example.com").searchParams;
|
||||
const evidenceHref = screen.getByRole("link", {
|
||||
name: "View next evidence page",
|
||||
}).getAttribute("href");
|
||||
const evidenceQuery =
|
||||
new URL(evidenceHref!, "https://example.com").searchParams;
|
||||
expect(evidenceQuery.get("change_id")).toBe("z/earlier & context");
|
||||
expect(evidenceQuery.get("evidence_cursor")).toBe("evidence/next");
|
||||
const nextHref = screen.getByRole("link", { name: /Next page/ }).getAttribute("href");
|
||||
const nextHref = screen.getByRole("link", { name: /Next page/ }).getAttribute(
|
||||
"href",
|
||||
);
|
||||
const nextQuery = new URL(nextHref!, "https://example.com").searchParams;
|
||||
expect(nextQuery.get("change_cursor")).toBe("change-next");
|
||||
expect(nextQuery.get("change_id")).toBe("z/earlier & context");
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchConfigHistory,
|
||||
fetchConfigTree,
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
previewConfigTree,
|
||||
} from "../../src/lib/workspace/config-source/api.ts";
|
||||
|
||||
function response(body: unknown, status = 200): Response {
|
||||
@@ -335,3 +336,24 @@ Deno.test("config source API carries bounded optional authoring schemas without
|
||||
}), ConfigSourceApiError);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("config source API previews a candidate without calling commit", async () => {
|
||||
const request = {
|
||||
base_digest: "sha256:base",
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const result = await previewConfigTree(
|
||||
"w/one",
|
||||
request,
|
||||
((input: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push(String(input));
|
||||
assertEquals(init?.method, "POST");
|
||||
assertEquals(JSON.parse(String(init?.body)), request);
|
||||
return Promise.resolve(response(tree));
|
||||
}) as typeof fetch,
|
||||
);
|
||||
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]);
|
||||
assertEquals<unknown>(result, tree);
|
||||
});
|
||||
|
||||
@@ -985,3 +985,61 @@ Deno.test("generated WASM rejects forged content identity at the snapshot bounda
|
||||
assertThrows(() => set_snapshot(forged));
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Plugin closed preflight shares native projection and diagnostic fixture without catalog authority", async () => {
|
||||
const fixture = JSON.parse(
|
||||
await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../../resources/config-schema/fixtures/plugin-preflight.json",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
) as {
|
||||
schema: string;
|
||||
cases: Array<{ source: string; mode?: string; diagnostic?: string }>;
|
||||
};
|
||||
const bundle = compose_schema_bundle([{
|
||||
provider_id: "plugin-preflight",
|
||||
namespace: "plugins",
|
||||
version: "1",
|
||||
source: fixture.schema,
|
||||
source_digest: await digestText(fixture.schema),
|
||||
}]) as WorkspaceConfigSchemaBundle;
|
||||
const contract: ToolchainContract = {
|
||||
contract_version: 2,
|
||||
decodal_version: "0.4.0",
|
||||
schema_version: 1,
|
||||
entrypoints: ["main.dcdl"],
|
||||
import_policy_version: 1,
|
||||
schema_bundle: bundle,
|
||||
fingerprint: await toolchainFingerprint(["main.dcdl"], bundle),
|
||||
};
|
||||
for (const testCase of fixture.cases) {
|
||||
const candidate = fixtureSnapshot(schemaSnapshot(testCase.source));
|
||||
if (testCase.diagnostic) {
|
||||
let failure: ProjectedDiagnostic[] = [];
|
||||
try {
|
||||
evaluate_snapshot(candidate, contract);
|
||||
} catch (error) {
|
||||
failure = error as ProjectedDiagnostic[];
|
||||
}
|
||||
assertEquals(Array.isArray(failure), true);
|
||||
assertEquals(failure[0].kind, testCase.diagnostic);
|
||||
assertEquals(failure[0].path, "main.dcdl");
|
||||
} else {
|
||||
const result = evaluate_snapshot(candidate, contract) as {
|
||||
projections: Array<
|
||||
{
|
||||
data_json: {
|
||||
plugins: { enabled: { echo: { config: { mode: string } } } };
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
assertEquals(
|
||||
result.projections[0].data_json.plugins.enabled.echo.config.mode,
|
||||
testCase.mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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