feat(ticket): expose case tags and independent assignment handling

This commit is contained in:
2026-10-12 07:33:11 +09:00
parent 56795fb92c
commit 9b0d42fdbc
79 changed files with 9326 additions and 713 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.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",
@@ -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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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
+16 -2
View File
@@ -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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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
@@ -87,6 +87,16 @@ export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string |
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; };
export type TicketAssignmentAnswerReceipt = { assignment_id: string; event_index: number; input_event_id: string; };
export type TicketAssignmentDiagnosticsPage = { actionable: boolean; assignment_id: string; current: boolean; input_origin_known: boolean; inputs: Array<TicketAssignmentInputDiagnostic>; next_input_event_id?: string | null; next_start_operation_id?: string | null; starts: Array<TicketAssignmentStartDiagnostic>; };
export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "request_persisted" | "runtime_accepted";
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 TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
@@ -97,6 +107,8 @@ export type TicketAssignmentReleaseAction = "unassigned" | "replaced";
export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor";
export type TicketAssignmentStartDiagnostic = { actual_processing_unknown: boolean; assignment_id?: string | null; claim_state: string; operation_id: string; runtime_id?: string | null; worker_id?: string | null; };
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
export type TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; };
@@ -111,6 +123,8 @@ export type TicketAssignmentTrackingStatus = "waiting" | "inprogress" | "done";
export type TicketAssignmentTrackingUpdate = { assignment_id: string; expected_last_event_id: string; operation_key: string; tracking: TicketAssignmentTracking; };
export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProjection>; next_assignment_id?: string | null; };
export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
@@ -155,7 +169,7 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; i
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
export type TicketQueryRequest = { attention?: Array<TicketAttention>; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; };
@@ -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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
// 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
@@ -41,6 +41,8 @@ import type {
TicketListItemSummary,
TicketListResponse,
TicketMergeRequestSummary,
TicketQueryItem,
TicketQueryResponse,
TicketQueueOutcome,
TicketRef,
TicketReference,
@@ -335,7 +337,10 @@ function caseProjection(item: Record<string, unknown>, label: string) {
};
}
function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag {
export function parseWorkspaceTag(
value: unknown,
label = "Workspace tag",
): WorkspaceTag {
const item = object(value, label);
exact(item, [
"workspace_id",
@@ -652,7 +657,7 @@ function parseTicketRoleAssignmentRecord(
};
}
function parseAssignmentTracking(
export function parseAssignmentTracking(
value: unknown,
label: string,
): TicketAssignmentTracking {
@@ -722,7 +727,7 @@ function parseAssignmentRelease(
};
}
function parseAssignmentTrackingProjection(
export function parseAssignmentTrackingProjection(
value: unknown,
label: string,
): TicketAssignmentTrackingProjection {
@@ -1750,3 +1755,152 @@ export function parseMergeRequestDetailResponse(
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
};
}
export function parseTicketQueryResponse(value: unknown): TicketQueryResponse {
const label = "Ticket query response";
const page = object(value, label);
exact(page, ["items", "page", "record_authority"], label);
return {
page: parseQueryPage(page.page, `${label}.page`),
record_authority: string(
page.record_authority,
`${label}.record_authority`,
),
items: array(
page.items,
`${label}.items`,
(value, label): TicketQueryItem => {
const item = object(value, label);
exact(item, [
"assignment_tracking",
"blocker_count",
"closure_operation_key",
"content_digest",
"created_at",
"current_closure",
"current_worker",
"evidence",
"id",
"legacy_handoff",
"linked_objective_ids",
"linked_objective_keys",
"matched_fields",
"matching_event",
"merge_request",
"merge_requests",
"priority",
"readiness",
"relation_count",
"resource_key",
"snippet",
"state",
"tags",
"title",
"unresolved_blocker_count",
"unresolved_review_count",
"updated_at",
"workspace_action_priority",
], label);
return {
id: string(item.id, `${label}.id`),
resource_key: string(item.resource_key, `${label}.resource_key`),
title: string(item.title, `${label}.title`),
state: parseTicketState(item.state, `${label}.state`),
priority: string(item.priority, `${label}.priority`),
assignment_tracking: array(
item.assignment_tracking,
`${label}.assignment_tracking`,
parseAssignmentTrackingProjection,
),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
blocker_count: integer(item.blocker_count, `${label}.blocker_count`),
unresolved_blocker_count: integer(
item.unresolved_blocker_count,
`${label}.unresolved_blocker_count`,
),
unresolved_review_count: integer(
item.unresolved_review_count,
`${label}.unresolved_review_count`,
),
relation_count: integer(
item.relation_count,
`${label}.relation_count`,
),
content_digest: string(
item.content_digest,
`${label}.content_digest`,
),
created_at: optionalNullableString(
item.created_at,
`${label}.created_at`,
),
updated_at: optionalNullableString(
item.updated_at,
`${label}.updated_at`,
),
closure_operation_key: optionalNullableString(
item.closure_operation_key,
`${label}.closure_operation_key`,
),
current_closure: item.current_closure == null
? item.current_closure as null | undefined
: parseClosure(item.current_closure, `${label}.current_closure`),
current_worker: item.current_worker == null
? item.current_worker as null | undefined
: parseTicketAssignmentSummary(
item.current_worker,
`${label}.current_worker`,
),
evidence: parseTicketEvidenceSummary(
item.evidence,
`${label}.evidence`,
),
legacy_handoff: item.legacy_handoff == null
? item.legacy_handoff as null | undefined
: parseLegacyHandoff(
item.legacy_handoff,
`${label}.legacy_handoff`,
),
linked_objective_ids: strings(
item.linked_objective_ids,
`${label}.linked_objective_ids`,
),
linked_objective_keys: strings(
item.linked_objective_keys,
`${label}.linked_objective_keys`,
),
matched_fields: strings(
item.matched_fields,
`${label}.matched_fields`,
),
matching_event: item.matching_event == null
? item.matching_event as null | undefined
: parseTicketEvidenceEvent(
item.matching_event,
`${label}.matching_event`,
),
merge_request: item.merge_request == null
? item.merge_request as null | undefined
: parseTicketMergeRequestSummary(
item.merge_request,
`${label}.merge_request`,
),
merge_requests: array(
item.merge_requests,
`${label}.merge_requests`,
parseTicketMergeRequestSummary,
),
readiness: optionalNullableString(
item.readiness,
`${label}.readiness`,
),
snippet: optionalNullableString(item.snippet, `${label}.snippet`),
workspace_action_priority: string(
item.workspace_action_priority,
`${label}.workspace_action_priority`,
),
};
},
),
};
}
@@ -0,0 +1,275 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
function ordered(value: unknown): unknown {
return Array.isArray(value)
? value.map(ordered)
: value && typeof value === "object"
? Object.fromEntries(
Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map((
[key, item],
) => [key, ordered(item)]),
)
: value;
}
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(ordered(actual)) !== JSON.stringify(ordered(expected))) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function assertThrows(operation: () => unknown): void {
try {
operation();
} catch {
return;
}
throw new Error("Expected rejection");
}
function assertStringIncludes(actual: string, expected: string): void {
if (!actual.includes(expected)) {
throw new Error(`${actual} does not include ${expected}`);
}
}
import type {
TicketAssignmentTrackingProjection,
TicketDetail,
} from "#lib/generated/ticket-api.ts";
import {
answerOperation,
assignmentOperation,
observeOperation,
operationError,
parseAnswerReceipt,
parseAssignmentDiagnostics,
parseAssignmentsPage,
parseTrackingHistory,
} from "./ticket-operations.ts";
const current: TicketAssignmentTrackingProjection = {
workspace_id: "team one",
ticket_id: "ticket/one",
assignment_id: "assignment-1",
role: "orchestrator",
current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
assigned_by: "account/user",
assigned_at: "2026-01-01T00:00:00Z",
tracking: {
status: "waiting",
waiting_reason: "User decision needed",
next_action: "Read answer",
},
last_event_id: "event-1",
updated_at: "2026-01-02T00:00:00Z",
};
function observed(): Pick<TicketDetail, "assignments"> {
return {
assignments: [{
assignment_id: current.assignment_id,
role: "orchestrator",
principal: current.principal,
assigned_at: current.assigned_at,
assigned_by: current.assigned_by,
}],
};
}
Deno.test("assignment retry retains the exact observed pointer, operation key and payload after new data arrives", () => {
const old = observed();
const operation = assignmentOperation(
"team one",
"ticket/one",
old,
"operation-1",
);
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" },
});
assertEquals(
operation.path,
"/api/w/team%20one/tickets/ticket%2Fone/assignments/orchestrator",
);
assertEquals(Object.isFrozen(operation), true);
const fresh = assignmentOperation(
"team one",
"ticket/one",
old,
"operation-2",
);
assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement");
const clear = assignmentOperation(
"team one",
"ticket/one",
old,
"release",
true,
);
assertStringIncludes(
clear.path,
"operation_id=release&assignment_id=replacement",
);
assertEquals(clear.method, "DELETE");
assertThrows(() =>
assignmentOperation("team", "ticket", { assignments: [] }, "clear", true)
);
});
Deno.test("waiting answers carry the same assignment and observed event without status or Assign mutation", () => {
const operation = answerOperation(
"team one",
"ticket/one",
current,
"My answer",
"answer-key",
);
assertEquals(JSON.parse(operation.body!), {
assignment_id: "assignment-1",
expected_last_event_id: "event-1",
body: "My answer",
operation_key: "answer-key",
});
assertEquals(
operation.path,
"/api/w/team%20one/tickets/ticket%2Fone/assignment-answer",
);
for (
const unavailable of [{ ...current, current: false }, {
...current,
last_event_id: null,
}, { ...current, tracking: { status: "done" as const } }]
) {
assertThrows(() =>
answerOperation("team", "ticket", unavailable, "answer", "new-op")
);
}
});
Deno.test("operation snapshots are serialized once rather than silently rebased for exact retry", () => {
const body = {
operation_key: "saved",
tag_ids: ["stable-id"],
expected_tag_ids: [] as string[],
};
const operation = observeOperation("/tags", "PUT", body);
body.tag_ids[0] = "renamed-id";
assertEquals(JSON.parse(operation.body!).tag_ids, ["stable-id"]);
});
Deno.test("delivery diagnostics keep saved notification, acceptance uncertainty and actual processing separate", () => {
const wire = {
assignment_id: "assignment-1",
current: true,
input_origin_known: true,
actionable: true,
inputs: [{
input_event_id: "input-1",
delivery_id: "delivery-1",
notification: "request_persisted" as const,
acceptance_outcome_unknown: true,
}],
starts: [{
operation_id: "start-1",
claim_state: "pending",
assignment_id: null,
runtime_id: null,
worker_id: null,
actual_processing_unknown: true,
}],
next_input_event_id: null,
next_start_operation_id: null,
};
assertEquals(parseAssignmentDiagnostics(wire), wire);
assertThrows(() =>
parseAssignmentDiagnostics({ ...wire, rendered_content: "must not leak" })
);
assertThrows(() =>
parseAssignmentDiagnostics({
...wire,
inputs: [{ ...wire.inputs[0], notification: "done" }],
})
);
assertThrows(() =>
parseAssignmentDiagnostics({
...wire,
inputs: Array(101).fill(wire.inputs[0]),
})
);
});
Deno.test("retained assignment page preserves result and release rather than rewriting handling to done", () => {
const row = {
...current,
current: false,
release: {
action: "replaced",
event_id: "release-event",
actor: "account/user",
at: current.assigned_at,
replacement_assignment_id: "assignment-2",
last_tracking: current.tracking,
},
};
const page = parseAssignmentsPage({
items: [row],
next_assignment_id: "assignment-1",
});
assertEquals(page.items[0].tracking?.status, "waiting");
assertEquals(page.items[0].release?.action, "replaced");
assertThrows(() =>
parseAssignmentsPage({
items: Array(101).fill(row),
next_assignment_id: null,
})
);
assertThrows(() =>
parseAssignmentsPage({
items: [row],
next_assignment_id: null,
guessed_status: "done",
})
);
assertThrows(() => parseTrackingHistory({ items: [], next_event_id: 1 }));
});
Deno.test("answer receipt rejects unsafe event indices and extra inferred result fields", () => {
assertEquals(
parseAnswerReceipt({
assignment_id: "a",
input_event_id: "event:1",
event_index: 1,
}),
{ assignment_id: "a", input_event_id: "event:1", event_index: 1 },
);
assertThrows(() =>
parseAnswerReceipt({
assignment_id: "a",
input_event_id: "event:1",
event_index: 2 ** 54,
})
);
assertThrows(() =>
parseAnswerReceipt({
assignment_id: "a",
input_event_id: "event:1",
event_index: 1,
status: "inprogress",
})
);
});
Deno.test("conflict, permission, unavailable and unknown errors never claim successful new delegation", () => {
assertStringIncludes(
operationError(new Error("request failed (409)")),
"Stale observation",
);
assertStringIncludes(
operationError(new Error("request failed (403)")),
"Permission denied",
);
assertStringIncludes(
operationError(new Error("request failed (404)")),
"unavailable",
);
assertStringIncludes(
operationError(new Error("network response lost")),
"Outcome unknown",
);
});
@@ -0,0 +1,293 @@
import type {
SetTicketRoleAssignmentRequest,
TicketAssignmentAnswer,
TicketAssignmentAnswerReceipt,
TicketAssignmentDiagnosticsPage,
TicketAssignmentsPage,
TicketAssignmentTrackingHistoryPage,
TicketAssignmentTrackingProjection,
TicketDetail,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
import { workspaceApiPath } from "./http.ts";
import {
parseAssignmentTracking,
parseAssignmentTrackingProjection,
parseWorkspaceTag,
} from "./ticket-browser.ts";
export function parseTags(value: unknown): WorkspaceTag[] {
if (!Array.isArray(value) || value.length > 1000) {
throw new Error("Tag list is invalid or unbounded");
}
return value.map((tag, index) => parseWorkspaceTag(tag, `Tags[${index}]`));
}
export function parseTracking(
value: unknown,
): TicketAssignmentTrackingProjection[] {
if (!Array.isArray(value) || value.length > 1000) {
throw new Error("Assignment list is invalid or unbounded");
}
return value.map((item, index) =>
parseAssignmentTrackingProjection(item, `Assignments[${index}]`)
);
}
export function parseTrackingHistory(
value: unknown,
): TicketAssignmentTrackingHistoryPage {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("History must be an object");
}
const page = value as Record<string, unknown>;
if (
Object.keys(page).some((key) =>
!["items", "next_event_id"].includes(key)
) || !Array.isArray(page.items) || page.items.length > 100
) throw new Error("History page is invalid");
const items = page.items.map((value, index) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("History entry must be an object");
}
const item = value as Record<string, unknown>;
if (
Object.keys(item).some((key) =>
!["at", "event_id", "operation_key", "source_context", "tracking"]
.includes(key)
)
) throw new Error("Unexpected history field");
for (const key of ["at", "event_id", "operation_key"]) {
if (
typeof item[key] !== "string" || (item[key] as string).length > 1024
) throw new Error(`History ${key} is invalid`);
}
return {
at: item.at as string,
event_id: item.event_id as string,
operation_key: item.operation_key as string,
source_context: item.source_context,
tracking: parseAssignmentTracking(item.tracking, `History[${index}]`),
};
});
if (
page.next_event_id != null &&
(typeof page.next_event_id !== "string" || page.next_event_id.length > 1024)
) throw new Error("History cursor is invalid");
return {
items,
next_event_id: page.next_event_id as string | null | undefined,
};
}
export function trackingPath(workspaceId: string, ticketId: string): string {
return `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${
encodeURIComponent(ticketId)
}/assignment-tracking`;
}
export type ObservedOperation = Readonly<
{ path: string; method: "POST" | "PUT" | "DELETE"; body?: string }
>;
// Keep the exact serialized input and comparison subject for explicit retry. A new
// observation must never silently rebase an ambiguous operation onto a new assignment.
export function observeOperation(
path: string,
method: ObservedOperation["method"],
body?: unknown,
): ObservedOperation {
return Object.freeze({
path,
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
export function assignmentOperation(
workspaceId: string,
ticketId: string,
observed: Pick<TicketDetail, "assignments">,
operationId: string,
clear = false,
principal: SetTicketRoleAssignmentRequest["principal"] = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
},
): ObservedOperation {
const current = observed.assignments.find((row) =>
row.role === "orchestrator"
);
const path = workspaceApiPath(
workspaceId,
`/tickets/${encodeURIComponent(ticketId)}/assignments/orchestrator`,
);
if (clear) {
if (!current) throw new Error("No observed current assignment to release");
return observeOperation(
`${path}?${new URLSearchParams({
operation_id: operationId,
assignment_id: current.assignment_id,
})}`,
"DELETE",
);
}
const request: SetTicketRoleAssignmentRequest = {
operation_id: operationId,
expected_assignment_id: current?.assignment_id ?? null,
principal,
};
return observeOperation(path, "PUT", request);
}
export function operationError(error: unknown): string {
const text = error instanceof Error ? error.message : String(error);
if (/\(409\)/.test(text)) {
return `Stale observation / conflict. Refresh and reconcile; do not overwrite or retry as a new operation. ${text}`;
}
if (/\(401\)|\(403\)/.test(text)) {
return `Permission denied. No access was granted. ${text}`;
}
if (/\(404\)/.test(text)) {
return `Resource or assignment unavailable. ${text}`;
}
if (/\(400\)|\(422\)/.test(text)) return `Input rejected. ${text}`;
return `Outcome unknown. This may already be saved; retain the same operation key for exact retry. ${text}`;
}
function record(value: unknown, keys: string[]): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Expected API object");
}
const item = value as Record<string, unknown>;
if (Object.keys(item).some((key) => !keys.includes(key))) {
throw new Error("Unexpected API field");
}
return item;
}
function text(value: unknown): string {
if (typeof value !== "string" || value.length > 16384) {
throw new Error("Expected bounded string");
}
return value;
}
function optionalText(value: unknown): string | null | undefined {
return value == null ? value as null | undefined : text(value);
}
function flag(value: unknown): boolean {
if (typeof value !== "boolean") throw new Error("Expected boolean");
return value;
}
function pageItems<T>(value: unknown, parse: (value: unknown) => T): T[] {
if (!Array.isArray(value) || value.length > 100) {
throw new Error("Expected bounded page");
}
return value.map(parse);
}
export function parseAssignmentsPage(value: unknown): TicketAssignmentsPage {
const page = record(value, ["items", "next_assignment_id"]);
return {
items: pageItems(
page.items,
(item) => parseAssignmentTrackingProjection(item, "Assignment page"),
),
next_assignment_id: optionalText(page.next_assignment_id),
};
}
export function parseAnswerReceipt(
value: unknown,
): TicketAssignmentAnswerReceipt {
const item = record(value, [
"assignment_id",
"input_event_id",
"event_index",
]);
if (
!Number.isSafeInteger(item.event_index) || (item.event_index as number) < 0
) throw new Error("Answer event index invalid");
return {
assignment_id: text(item.assignment_id),
input_event_id: text(item.input_event_id),
event_index: item.event_index as number,
};
}
export function answerOperation(
workspaceId: string,
ticketId: string,
observed: TicketAssignmentTrackingProjection,
body: string,
operationKey: string,
): ObservedOperation {
if (
!observed.current || !observed.last_event_id || !observed.tracking ||
observed.tracking.status === "done"
) throw new Error("Read actionable current assignment before answering");
const request: TicketAssignmentAnswer = {
assignment_id: observed.assignment_id,
expected_last_event_id: observed.last_event_id,
body,
operation_key: operationKey,
};
return observeOperation(
workspaceApiPath(
workspaceId,
`/tickets/${encodeURIComponent(ticketId)}/assignment-answer`,
),
"POST",
request,
);
}
export function parseAssignmentDiagnostics(
value: unknown,
): TicketAssignmentDiagnosticsPage {
const page = record(value, [
"assignment_id",
"current",
"input_origin_known",
"actionable",
"inputs",
"next_input_event_id",
"starts",
"next_start_operation_id",
]);
return {
assignment_id: text(page.assignment_id),
current: flag(page.current),
input_origin_known: flag(page.input_origin_known),
actionable: flag(page.actionable),
next_input_event_id: optionalText(page.next_input_event_id),
next_start_operation_id: optionalText(page.next_start_operation_id),
inputs: pageItems(page.inputs, (value) => {
const item = record(value, [
"input_event_id",
"delivery_id",
"notification",
"acceptance_outcome_unknown",
]);
if (
!["not_reserved", "reserved", "request_persisted", "runtime_accepted"]
.includes(item.notification as string)
) throw new Error("Unexpected notification state");
return {
input_event_id: text(item.input_event_id),
delivery_id: optionalText(item.delivery_id),
notification: item
.notification as TicketAssignmentDiagnosticsPage["inputs"][number][
"notification"
],
acceptance_outcome_unknown: flag(item.acceptance_outcome_unknown),
};
}),
starts: pageItems(page.starts, (value) => {
const item = record(value, [
"operation_id",
"claim_state",
"assignment_id",
"runtime_id",
"worker_id",
"actual_processing_unknown",
]);
return {
operation_id: text(item.operation_id),
claim_state: text(item.claim_state),
assignment_id: optionalText(item.assignment_id),
runtime_id: optionalText(item.runtime_id),
worker_id: optionalText(item.worker_id),
actual_processing_unknown: flag(item.actual_processing_unknown),
};
}),
};
}
@@ -392,16 +392,13 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
);
assert(
ticketsLoad.includes("loadLane(fetch, workspaceId") &&
ticketsLoad.includes('limit: "30"') &&
ticketsLoad.includes('states: states.join(",")') &&
ticketsLoad.includes("/tickets?${search}") &&
!ticketsLoad.includes("/tickets/query") &&
ticketsLoad.includes("ticketQueryRequest(filters, [state])") &&
ticketsLoad.includes("/tickets/query") &&
ticketsLoad.includes('method: "POST"') &&
ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes("laneState") &&
ticketsPage.includes("loadMore(lane.id)") &&
ticketsPage.includes("handleLaneScroll(event, lane.id)"),
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
ticketsPage.includes("loadMore(lane.id)"),
"Tickets list must apply shared server query filters before paging case lanes",
);
assert(
ticketPanelModel.includes('label: "Open"') &&
@@ -428,14 +425,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("TicketHandling") &&
ticketDetailPage.includes("const path = ticketPath") &&
ticketDetailPage.includes("`${path}/queue`") &&
!ticketDetailPage.includes("queueTicket") &&
!ticketDetailPage.includes("markReady") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
ticketDetailPage.includes("mergeRequestPagePath") &&
ticketDetailPage.includes("await updateTicketCase(") &&
ticketDetailPage.includes("runCaseDecision") &&
ticketDetailPage.includes("TicketTags") &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_worker") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
@@ -49,6 +49,7 @@ export function fixtureDetail(key = "T-101") {
artifacts: [],
artifact_count: 0,
assignments: [],
assignment_tracking: [],
assignment_diagnostics: [],
events: [],
event_count: 0,
@@ -0,0 +1,219 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentDiagnosticsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, 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, parseAssignmentDiagnostics, 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();
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
let busy = $state(false);
let pending = $state<ObservedOperation | null>(null);
let error = $state<string | null>(null);
let savedNotice = $state<string | null>(null);
let pendingKind = $state<"assignment" | "answer">("assignment");
let assignments = $state<TicketAssignmentsPage | null>(null);
let assignmentPageBusy = $state(false);
let diagnostic = $state<TicketAssignmentDiagnosticsPage | null>(null);
let diagnosticError = $state<string | null>(null);
let diagnosticBusy = $state(false);
let diagnosticRead = 0;
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 historyId = $state("");
let retained = $state<TicketAssignmentTrackingProjection | null>(null);
let history = $state<TicketAssignmentTrackingHistoryPage | null>(null);
let historyError = $state<string | null>(null);
let historyBusy = $state(false);
let alive = true;
onDestroy(() => { alive = false; });
async function run(operation: ObservedOperation, kind: "assignment" | "answer" = pendingKind) {
if (busy) return;
busy = true; error = null; pending = operation; pendingKind = kind; savedNotice = null;
try {
if (kind === "answer") {
const receipt = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseAnswerReceipt, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
savedNotice = `Answer saved to assignment ${receipt.assignment_id}, input ${receipt.input_event_id}. Handling status is unchanged.`;
answer = "";
} else {
const result = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}. Delivery and execution are separate.`;
}
pending = null;
await refresh();
await readAssignments();
if (current) await readDiagnostics(current.assignment_id);
} catch (cause) { if (alive) error = savedNotice ? `Operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
finally { busy = false; }
}
function delegate(clear = false) {
if (!clear && !principalReady) return;
void run(assignmentOperation(workspaceId, ticket.id, ticket, crypto.randomUUID(), clear, selectedPrincipal()), "assignment");
}
async function reconcile() {
if (busy) return;
busy = true;
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); if (current) await readDiagnostics(current.assignment_id); }
catch (cause) { if (alive) error = operationError(cause); }
finally { busy = false; }
}
async function readHistory(assignmentId: string, more = false) {
if (!assignmentId || historyBusy) return;
historyBusy = true; historyError = null;
const root = trackingPath(workspaceId, ticket.id);
try {
const rows = await workspaceApiJson(`${root}?${new URLSearchParams({ assignment_id: assignmentId })}`, parseTracking, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
retained = rows[0] ?? null;
const search = new URLSearchParams({ assignment_id: assignmentId, limit: "20" });
if (more && history?.next_event_id) search.set("after_event_id", history.next_event_id);
const page = await workspaceApiJson(`${root}/history?${search}`, parseTrackingHistory, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
history = more && history ? { ...page, items: [...history.items, ...page.items] } : page;
historyId = assignmentId;
} catch (cause) { historyError = operationError(cause); }
finally { historyBusy = false; }
}
async function readAssignments(more = false) {
if (assignmentPageBusy) return;
assignmentPageBusy = true;
try {
const query = new URLSearchParams({ limit: "20" });
if (more && assignments?.next_assignment_id) query.set("after_assignment_id", assignments.next_assignment_id);
const page = await workspaceApiJson(trackingPath(workspaceId, ticket.id) + "/assignments?" + query, parseAssignmentsPage, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
assignments = more && assignments ? { ...page, items: [...assignments.items, ...page.items] } : page;
} catch (cause) { historyError = operationError(cause); }
finally { assignmentPageBusy = false; }
}
async function readDiagnostics(id: string, more = false) {
const read = ++diagnosticRead;
diagnosticBusy = true; diagnosticError = null;
try {
const query = new URLSearchParams({ assignment_id: id, limit: "20" });
if (more && diagnostic?.assignment_id === id) {
const inputCursor = diagnostic.next_input_event_id ?? diagnostic.inputs.at(-1)?.input_event_id;
const startCursor = diagnostic.next_start_operation_id ?? diagnostic.starts.at(-1)?.operation_id;
if (inputCursor) query.set("after_input_event_id", inputCursor);
if (startCursor) query.set("after_start_operation_id", startCursor);
}
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/tickets/" + encodeURIComponent(ticket.id) + "/assignment-diagnostics?") + query, parseAssignmentDiagnostics, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive || read !== diagnosticRead) return;
diagnostic = more && diagnostic?.assignment_id === id ? { ...page, inputs: [...diagnostic.inputs, ...page.inputs], starts: [...diagnostic.starts, ...page.starts] } : page;
} catch (cause) { if (alive && read === diagnosticRead) diagnosticError = operationError(cause); }
finally { if (read === diagnosticRead) diagnosticBusy = false; }
}
function submitAnswer(event: SubmitEvent) {
event.preventDefault();
if (!current || pending || !answer.trim()) return;
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
}
onMount(() => { void readAssignments(); if (current) void readDiagnostics(current.assignment_id); });
</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>
{#if current}
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
<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>
{#if current.tracking}
<dl>
{#if current.tracking.waiting_reason}<dt>Waiting reason</dt><dd>{current.tracking.waiting_reason}</dd>{/if}
{#if current.tracking.progress}<dt>Progress</dt><dd>{current.tracking.progress}</dd>{/if}
{#if current.tracking.next_action}<dt>Next action</dt><dd>{current.tracking.next_action}</dd>{/if}
{#if current.tracking.result}<dt>Result of this handling</dt><dd>{current.tracking.result}</dd>{/if}
</dl>
{/if}
{#if current.updated_at}<p>Updated {formatDate(current.updated_at)}</p>{/if}
{#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}
<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>
{:else}<p class="workspace-empty-copy">No current Orchestrator 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}
<p>Unresolved operation. New delegation is disabled until reconciled.</p>
<button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending)}>Retry exact operation</button>
<button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile assignment</button>
{/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>
<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}
</fieldset>
<p>Backend validates the selected principal. Responsibility does not choose 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>
{: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"}
<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>
<p>Continues assignment <code>{current.assignment_id}</code>; not a new delegation. A stale assignment is rejected.</p>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
</form>
{/if}
<details class="ticket-assignment-history"><summary>Past assignment and release history</summary>
{#if assignmentPageBusy}<p role="status">Loading assignment history…</p>{/if}
{#each assignments?.items ?? [] as row (row.assignment_id)}<p><button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(row.assignment_id)}>{row.current ? "Current" : "Past"} · {row.tracking?.status ?? "unknown"} · {formatDate(row.assigned_at)}</button> <code>{row.assignment_id}</code>{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}</p>{/each}
{#if assignments?.next_assignment_id}<button class="workspace-secondary-button" disabled={assignmentPageBusy} onclick={() => void readAssignments(true)}>More assignments</button>{/if}
<form onsubmit={(event) => { event.preventDefault(); void readHistory(historyId); }}><label>Assignment identity<input bind:value={historyId} required /></label><button class="workspace-secondary-button" disabled={historyBusy}>Read assignment</button></form>
{#if historyError}<p role="alert" class="workspace-callout is-error">{historyError}</p>{/if}
{#if retained}
<p><code>{retained.assignment_id}</code> · {retained.current ? "current" : "past"} · last handling {retained.tracking?.status ?? "unknown"}</p>
{#if retained.tracking?.result}<p>Previous result: {retained.tracking.result}</p>{/if}
{#if retained.release}<p>{retained.release.action} by {retained.release.actor} · {formatDate(retained.release.at)}</p>{#if retained.release.replacement_assignment_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(retained!.release!.replacement_assignment_id!)}>Read replacement assignment</button>{/if}{/if}
{/if}
{#if history}{#each history.items as entry (entry.event_id)}<article><p>{entry.tracking.status} · {formatDate(entry.at)}</p>{#if entry.tracking.waiting_reason}<p>Waiting reason: {entry.tracking.waiting_reason}</p>{/if}{#if entry.tracking.progress}<p>Progress: {entry.tracking.progress}</p>{/if}{#if entry.tracking.next_action}<p>Next action: {entry.tracking.next_action}</p>{/if}{#if entry.tracking.result}<p>Result: {entry.tracking.result}</p>{/if}</article>{/each}{#if history.next_event_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(historyId, true)}>More handling history</button>{/if}{/if}
</details>
</section>
<section class="ticket-detail-section ticket-delivery">
<div class="ticket-section-heading"><h2>Delivery and execution</h2></div>
<p>Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}</p>
{#if current}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => current && void readDiagnostics(current.assignment_id)}>Refresh delivery diagnostics</button>{/if}
{#if diagnosticError}<p class="workspace-callout is-error" role="alert">{diagnosticError}</p>{/if}
{#if diagnosticBusy}<p role="status">Loading delivery diagnostics…</p>{/if}
{#if diagnostic}
<p>Assignment <code>{diagnostic.assignment_id}</code> · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}</p>
{#if !diagnostic.input_origin_known}<p>Input origin unknown; not inferred from handling.</p>{/if}
{#each diagnostic.inputs as input (input.input_event_id)}
<article><p>Input <code>{input.input_event_id}</code></p><dl><dt>Notification</dt><dd>{input.notification.replaceAll("_", " ")}</dd><dt>Runtime acceptance</dt><dd>{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}</dd></dl></article>
{:else}<p>No delivery input records in this page.</p>{/each}
<h3>Work start / admission evidence</h3>
{#each diagnostic.starts as start (start.operation_id)}<p><code>{start.operation_id}</code>: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}</p>{:else}<p>No work start records observed. This is not inferred from Worker state.</p>{/each}
{#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => diagnostic && void readDiagnostics(diagnostic.assignment_id, true)}>More delivery / start records</button>{/if}
{:else}<p>Delivery / Runtime acceptance / work start not observed.</p>{/if}
</section>
<section class="ticket-detail-section ticket-orchestrator-worker">
<div class="ticket-section-heading"><h2>Orchestrator Worker</h2></div>
{#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}
</section>
<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(7rem, 1fr) minmax(0, 3fr); gap: 0.5rem 1rem; }
dt { color: var(--text-muted); } dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; } .handling-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 1rem; }
article + article { margin-top: 1rem; }
@media (max-width: 540px) { dl { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,100 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { TicketDetail, WorkspaceTag, WorkspaceTagMutation, TicketTagSetRequest } from "#lib/generated/ticket-api.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkspaceTag, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
import { observeOperation, operationError, parseTags, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
let { workspaceId, ticket, refresh }: { workspaceId: string; ticket: TicketDetail; refresh: () => Promise<void> } = $props();
let catalog = $state<WorkspaceTag[]>([]);
let selected = $state<string[]>([]);
let loading = $state(true);
let busy = $state(false);
let error = $state<string | null>(null);
let pending = $state<ObservedOperation | null>(null);
let saved = $state(false);
let name = $state("");
let tagId = $state("");
const observed = $derived(catalog.find((tag) => tag.id === tagId));
let alive = true;
onDestroy(() => { alive = false; });
const catalogPath = $derived(workspaceApiPath(workspaceId, "/ticket-tags"));
async function load() {
loading = true;
try {
catalog = await workspaceApiJson(catalogPath, parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
selected = ticket.tags.map((tag) => tag.id);
if (!pending) error = null;
return true;
} catch (cause) { if (alive) error = operationError(cause); return false; }
finally { loading = false; }
}
onMount(() => { void load(); });
async function run(operation: ObservedOperation, tagResult: boolean) {
if (busy) return;
busy = true; pending = operation; saved = false; error = null;
try {
await workspaceApiJsonWithBody<WorkspaceTag | WorkspaceTag[]>(operation.path, { method: operation.method, body: operation.body }, tagResult ? parseWorkspaceTag : parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
pending = null; saved = true;
await refresh(); await load();
} catch (cause) { if (alive) error = saved ? `Tag operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
finally { busy = false; }
}
let retryTagResult = $state(false);
function mutateTag(action: WorkspaceTagMutation["action"]) {
retryTagResult = true;
void run(observeOperation(catalogPath, "POST", { action, operation_key: crypto.randomUUID() } satisfies WorkspaceTagMutation), true);
}
function setTags() {
retryTagResult = false;
const request: TicketTagSetRequest = { expected_tag_ids: ticket.tags.map((tag) => tag.id), tag_ids: selected, operation_key: crypto.randomUUID() };
void run(observeOperation(workspaceApiPath(workspaceId, `/tickets/${encodeURIComponent(ticket.id)}/tags`), "PUT", request), false);
}
async function reconcile() {
if (busy) return;
busy = true;
try { await refresh(); if (!alive || !await load()) return; pending = null; error = null; saved = false; }
catch (cause) { if (alive) error = operationError(cause); }
finally { busy = false; }
}
</script>
<section class="ticket-detail-section ticket-tags">
<div class="ticket-section-heading"><h2>Classification tags</h2></div>
<p class="workspace-empty-copy">Classification only. Tag changes do not close or reopen this case.</p>
<div class="ticket-tag-chips">{#each ticket.tags as tag (tag.id)}<span class="ticket-tag-chip">{tag.name}</span>{:else}<span>No tags</span>{/each}</div>
{#if loading}<p role="status">Loading tag catalog…</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if saved}<p role="status">Tag operation saved.</p>{/if}
{#if pending}
<p>Pending operation retained; new operations are disabled.</p>
<button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending, retryTagResult)}>Retry exact tag operation</button>
<button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile tags</button>
{/if}
<details><summary>Edit classification</summary>
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); setTags(); }}>
<fieldset disabled={busy || loading || pending !== null}><legend>Multiple tags</legend>
{#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<label><input type="checkbox" bind:group={selected} value={tag.id} />{tag.name}</label>{/each}
</fieldset>
<button class="workspace-secondary-button" disabled={busy || loading || pending !== null}>Save classification</button>
</form>
</details>
<details><summary>Manage Workspace tags</summary>
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); mutateTag({ kind: "create", name: name.trim() }); }}>
<label>Tag name<input bind:value={name} required disabled={busy || pending !== null} /></label>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()}>Create tag</button>
</form>
<label>Existing tag<select bind:value={tagId} disabled={busy || pending !== null}><option value="">Select tag</option>{#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<option value={tag.id}>{tag.name}</option>{/each}</select></label>
{#if observed}
<p><code>{observed.id}</code></p>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()} onclick={() => mutateTag({ kind: "rename", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key, name: name.trim() })}>Rename tag</button>
<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm(`Delete classification tag ${observed.name}? Historical audit is retained.`)) mutateTag({ kind: "delete", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key }); }}>Delete tag</button>
{/if}
</details>
</section>
<style>
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem); margin-block: 0.75rem; }
.ticket-tag-chip { padding: 0.2rem 0.5rem; background: var(--bg-subtle); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
fieldset label { display: flex; align-items: center; gap: 0.5rem; }
fieldset input { width: auto; }
code { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,37 @@
<script lang="ts">
import { untrack, onDestroy } from "svelte";
import type { TicketDetail } from "#lib/generated/ticket-api.ts";
import type { WorkerSummary } from "#lib/generated/worker-launch-api.ts";
import { workspaceApiJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
import { workerHref } from "#lib/workspace/resource-links.ts";
let { workspaceId, ticket }: { workspaceId: string; ticket: TicketDetail } = $props();
let worker = $state<WorkerSummary | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
let observationRequest = 0;
onDestroy(() => { observationRequest += 1; });
async function load() {
const request = ++observationRequest;
const reference = ticket.current_worker?.worker_resource_key;
worker = null; error = null;
if (!reference) { loading = false; return; }
loading = true;
try {
const observed = await workspaceApiJson(workspaceApiPath(workspaceId, `/workers/${encodeURIComponent(reference)}`), parseWorkerSummary, 1024 * 1024);
if (request !== observationRequest) return;
worker = observed;
}
catch (cause) { if (request === observationRequest) error = cause instanceof Error ? cause.message : String(cause); }
finally { if (request === observationRequest) loading = false; }
}
$effect(() => { void ticket.current_worker?.worker_resource_key; untrack(() => { void load(); }); });
</script>
<section class="ticket-detail-section ticket-worker-observation">
<div class="ticket-section-heading"><h2>Related Worker — actual state</h2><button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button></div>
{#if loading}<p role="status">Loading Worker observation…</p>{/if}
{#if error}<p role="alert" class="workspace-callout is-error">Worker observation unavailable: {error}</p>{/if}
{#if worker}<p>{worker.runtime_id}/{worker.worker_id}: <strong>{worker.state}</strong> · availability {worker.availability}</p><a href={workerHref(workspaceId, { resource_key: worker.resource_key, display_name: worker.display_name ?? worker.label })}>Open Worker</a>
{:else}<p class="workspace-empty-copy">{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}</p>{/if}
<p class="workspace-empty-copy">Worker state does not determine Ticket case state or self-reported handling.</p>
</section>
@@ -0,0 +1,214 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type {
TicketAssignmentTrackingProjection,
TicketDetail,
} from "#lib/generated/ticket-api.ts";
import { fixtureDetail } from "../home/dashboard.test-fixtures.ts";
import TicketHandling from "./TicketHandling.svelte";
import TicketTags from "./TicketTags.svelte";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const assignment = (
id = "delegation-1",
status: "waiting" | "done" = "waiting",
): TicketAssignmentTrackingProjection => ({
workspace_id: "team",
ticket_id: "T-1",
assignment_id: id,
role: "orchestrator",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
assigned_by: "user",
assigned_at: "2026-01-01T00:00:00Z",
current: true,
tracking: {
status,
waiting_reason: status === "waiting" ? "User answer needed" : null,
result: status === "done" ? "Options returned" : null,
},
last_event_id: "event-1",
});
function detail(row = assignment()): TicketDetail {
return {
...fixtureDetail("T-1"),
action_eligibility: {
...fixtureDetail("T-1").action_eligibility,
can_assign_orchestrator: true,
can_unassign_orchestrator: true,
},
assignment_tracking: [row],
assignments: [{
assignment_id: row.assignment_id,
role: row.role,
principal: row.principal,
assigned_by: row.assigned_by,
assigned_at: row.assigned_at,
}],
};
}
function reads(path: string, row: TicketAssignmentTrackingProjection) {
if (path.includes("/assignment-tracking/assignments")) {
return Response.json({ items: [row], next_assignment_id: null });
}
if (path.includes("/assignment-diagnostics")) {
return Response.json({
assignment_id: row.assignment_id,
current: true,
actionable: true,
input_origin_known: true,
inputs: [{
input_event_id: "input",
delivery_id: "delivery",
notification: "request_persisted",
acceptance_outcome_unknown: true,
}],
starts: [],
next_input_event_id: null,
next_start_operation_id: null,
});
}
throw new Error(`Unexpected read ${path}`);
}
test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => {
const row = assignment();
const sent: string[] = [];
let attempts = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path.endsWith("/assignment-answer")) {
sent.push(String(init?.body));
if (++attempts === 1) throw new Error("response lost");
return Response.json({
assignment_id: row.assignment_id,
input_event_id: "input-2",
event_index: 2,
});
}
return reads(path, row);
}),
);
const refresh = vi.fn(async () => {});
const view = render(TicketHandling, {
workspaceId: "team",
ticket: detail(row),
orchestrator: null,
refresh,
});
await fireEvent.input(
view.getByLabelText("Answer / continue this handling"),
{ target: { value: "Use option B" } },
);
await fireEvent.click(view.getByText("Send answer to this assignment"));
await waitFor(() => expect(view.getByText(/Outcome unknown\./)).toBeTruthy());
await view.rerender({ ticket: detail(assignment("delegation-2")) });
expect(
(view.getByText("Send answer to this assignment") as HTMLButtonElement)
.disabled,
).toBe(true);
await fireEvent.click(view.getByText("Retry exact operation"));
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
expect(sent).toHaveLength(2);
expect(sent[1]).toBe(sent[0]);
expect(JSON.parse(sent[1])).toEqual({
assignment_id: "delegation-1",
expected_last_event_id: "event-1",
body: "Use option B",
operation_key: expect.any(String),
});
expect(view.container.textContent).toContain("Handling status is unchanged");
});
test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => {
const row = assignment("delegation-1", "done");
const sent: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
sent.push(JSON.parse(String(init.body)));
return Response.json({
workspace_id: "team",
ticket_id: "T-1",
assignment: {
assignment_id: "delegation-2",
workspace_id: "team",
ticket_id: "T-1",
role: "orchestrator",
principal: row.principal,
assigned_at: row.assigned_at,
assigned_by: row.assigned_by,
},
});
}
return reads(String(input), row);
}),
);
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("Request again (new assignment)"));
await waitFor(() =>
expect(view.getByText(/Assignment operation saved: delegation-2/))
.toBeTruthy()
);
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" });
expect(view.queryByText("Retry exact operation")).toBeNull();
expect(view.container.textContent).toContain("Outcome unknown");
expect(view.container.textContent).toContain(
"Outcome unknown — may already be accepted",
);
expect(view.container.textContent).toContain(
"No work start records observed",
);
expect(view.container.textContent).toContain("Self-reported handling: done");
});
test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => {
const tag = {
workspace_id: "team",
id: "tag-1",
name: "closed",
last_operation_key: "tag-create",
deleted: false,
};
const requests: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
requests.push(String(init.body));
return new Response("", { status: 409 });
}
return Response.json([tag]);
}),
);
const view = render(TicketTags, {
workspaceId: "team",
ticket: { ...detail(), tags: [] },
refresh: vi.fn(async () => {}),
});
await waitFor(() => expect(view.queryByText("Loading tag catalog…")).toBeNull());
await fireEvent.click(view.getByLabelText("closed"));
await fireEvent.click(view.getByText("Save classification"));
await waitFor(() => expect(view.getByText(/Stale observation/)).toBeTruthy());
await view.rerender({ ticket: { ...detail(), tags: [tag] } });
await fireEvent.click(view.getByText("Retry exact tag operation"));
await waitFor(() => expect(requests).toHaveLength(2));
expect(requests[1]).toBe(requests[0]);
expect(JSON.parse(requests[1])).toMatchObject({
expected_tag_ids: [],
tag_ids: ["tag-1"],
});
expect((view.getByText("Save classification") as HTMLButtonElement).disabled)
.toBe(true);
});
@@ -1,7 +1,13 @@
import {
closureReasonLabel,
currentOrchestratorAssignment,
handlingLabel,
handlingSummary,
nextTicketLaneVisibleCount,
readTicketFilters,
TICKET_LANE_PAGE_SIZE,
ticketLanes,
ticketQueryRequest,
ticketWorkerLaunchHref,
ticketWorkerMessage,
} from "./ticket-panel.ts";
@@ -112,44 +118,107 @@ Deno.test("generic Ticket launch carries natural text without role or resource c
}
});
Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
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",
),
);
for (const cursor of [null, "next-page"]) {
const request = JSON.parse(
JSON.stringify(ticketQueryRequest(filters, ["open"], cursor)),
);
assertEquals(request, {
query: "options",
states: ["open"],
tag_ids: ["tag-1", "tag-2"],
assigned_principal: {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
},
handling_status: null,
sort: "updated_desc",
limit: 30,
cursor,
});
const handling = JSON.parse(
JSON.stringify(ticketQueryRequest(filters, [], cursor, "done")),
);
assertEquals(handling.handling_status, "done");
assertEquals(handling.states, []);
}
});
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
assertEquals(source.includes("outcome.queued_tickets.join"), true);
Deno.test("current close-reason queries preserve known and custom reasons on every page without changing case intent", () => {
for (
const [input, expected] of [["completed", "completed"], [
"other&reason_text=Superseded%20plan",
{ other: "Superseded plan" },
]] as const
) {
const filters = readTicketFilters(
new URLSearchParams(`case=closed&reason=${input}`),
);
for (const cursor of [null, "next-page"]) {
const request = JSON.parse(
JSON.stringify(ticketQueryRequest(filters, ["closed"], cursor)),
);
assertEquals(request.closure_reason, expected);
assertEquals(request.states, ["closed"]);
assertEquals(request.cursor, cursor);
}
}
});
Deno.test("handling summary uses only the current self-report and never Worker or case status", () => {
const row = {
assignment_id: "a",
current: true,
role: "orchestrator",
tracking: { status: "waiting", waiting_reason: "Awaiting user choice" },
} as TicketDetail["assignment_tracking"][number];
for (const state of ["open", "closed"] as const) {
for (const workerState of ["Running", "Stopped"]) {
const value = {
state,
workerState,
assignment_tracking: [{
...row,
assignment_id: "released",
current: false,
tracking: { status: "done" as const, result: "Old result" },
}, row],
};
const current = currentOrchestratorAssignment(value);
assertEquals(current?.assignment_id, "a");
assertEquals(handlingLabel(current?.tracking?.status), "Waiting");
assertEquals(handlingSummary(current), "Awaiting user choice");
}
}
assertEquals(
source.includes("resolve the listed blockers before Queue"),
false,
handlingSummary({
...row,
tracking: { status: "done", result: "Compared options" },
}),
"Compared options",
);
assertEquals(
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
true,
handlingSummary({
...row,
tracking: null,
migration_diagnostic: "Unknown historical handling",
}),
"Unknown historical handling",
);
assertEquals(handlingLabel(null), "Unknown");
assertEquals(
source.includes('mutateAssignment("start-manual", "worker"'),
true,
);
assertEquals(
source.includes('mutateAssignment("start-manual", "reviewer"'),
false,
);
assertEquals(source.includes('kind: "workspace_agent"'), true);
assertEquals(source.includes('kind: "worker"'), true);
assertEquals(source.includes("ticket.assignee"), false);
assertEquals(source.includes('action: "set", targets'), true);
assertEquals(source.includes("updatedTicket.targets"), true);
assertEquals(source.includes("ticket.repository_key"), false);
assertEquals(source.includes("ticket.ref_selector"), false);
assertEquals(
source.includes("(repository.diagnostics ?? []).length > 0"),
false,
currentOrchestratorAssignment({
assignment_tracking: [{ ...row, current: false }],
}),
null,
);
assertEquals(handlingSummary(null), "Not assigned");
assertEquals(closureReasonLabel({ other: "Superseded" }), "Superseded");
});
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
@@ -1,7 +1,13 @@
import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
import type {
TicketAssignmentPrincipalSnapshot,
TicketAssignmentTrackingProjection,
TicketAssignmentTrackingStatus,
TicketClosureReason,
TicketDetail,
TicketListItemSummary,
TicketQueryItem,
TicketQueryRequest,
} from "#lib/generated/ticket-api.ts";
export const TICKET_STATES = ["open", "closed"] as const;
@@ -16,10 +22,14 @@ export const TICKET_LANE_PAGE_SIZE = 30;
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick<
TicketListItemSummary,
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
>;
export type TicketCardSummary =
& Pick<
TicketListItemSummary,
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
>
& Partial<
Pick<TicketQueryItem, "tags" | "current_closure" | "assignment_tracking">
>;
export type TicketLane = {
id: TicketLaneId;
@@ -81,6 +91,148 @@ export function ticketWorkerLaunchHref(
}/workers/new?${params.toString()}`;
}
export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const;
export const ORCHESTRATOR_PRINCIPAL = {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
} as const;
export function currentOrchestratorAssignment(
ticket: Pick<TicketQueryItem, "assignment_tracking">,
): TicketAssignmentTrackingProjection | null {
return ticket.assignment_tracking.find((row) =>
row.current && row.role === "orchestrator"
) ?? null;
}
export function handlingLabel(
status?: TicketAssignmentTrackingStatus | null,
): string {
return status === "waiting"
? "Waiting"
: status === "inprogress"
? "In progress"
: status === "done"
? "Done"
: "Unknown";
}
export function assignmentPrincipalLabel(
principal: TicketAssignmentPrincipalSnapshot,
): string {
if (principal.kind === "workspace_agent") {
return `Workspace agent: ${principal.agent_key}`;
}
if (principal.kind === "user") return `User account: ${principal.account_id}`;
return `Worker: ${principal.runtime_id}/${principal.worker_id}`;
}
export function handlingSummary(
row: TicketAssignmentTrackingProjection | null,
): string {
const tracking = row?.tracking;
if (!row) return "Not assigned";
if (!tracking) return row.migration_diagnostic ?? "Handling unavailable";
return tracking.status === "waiting"
? tracking.waiting_reason ?? "Waiting reason not reported"
: tracking.status === "inprogress"
? tracking.progress ?? "Progress not reported"
: tracking.result ?? "Result not reported";
}
export function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "object" ? reason.other : ({
completed: "Completed",
not_planned: "Not planned",
duplicate: "Duplicate",
unknown: "Unknown",
})[reason];
}
export type TicketFilters = {
query: string;
caseState: "all" | TicketState;
tagIds: string[];
principal: "all" | "orchestrator" | "user" | "worker";
accountId: string;
runtimeId: string;
workerId: string;
closureReason:
| "all"
| "completed"
| "not_planned"
| "duplicate"
| "unknown"
| "other";
closureText: string;
};
export function readTicketFilters(search: URLSearchParams): TicketFilters {
const principal = search.get("principal");
const state = search.get("case");
const reason = search.get("reason");
return {
query: search.get("q") ?? "",
caseState: state === "open" || state === "closed" ? state : "all",
tagIds: [...new Set(search.getAll("tag"))],
principal: principal === "orchestrator" || principal === "user" ||
principal === "worker"
? principal
: "all",
accountId: search.get("account") ?? "",
runtimeId: search.get("runtime") ?? "",
workerId: search.get("worker") ?? "",
closureReason:
["completed", "not_planned", "duplicate", "unknown", "other"].includes(
reason ?? "",
)
? reason as TicketFilters["closureReason"]
: "all",
closureText: search.get("reason_text") ?? "",
};
}
export function filterPrincipal(
filters: TicketFilters,
): TicketAssignmentPrincipalSnapshot | null {
if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL;
if (filters.principal === "user") {
if (!filters.accountId.trim()) {
throw new Error("Enter an account ID for the user principal filter");
}
return { kind: "user", account_id: filters.accountId.trim() };
}
if (filters.principal === "worker") {
if (!filters.runtimeId.trim() || !filters.workerId.trim()) {
throw new Error(
"Enter Runtime and Worker IDs for the Worker principal filter",
);
}
return {
kind: "worker",
runtime_id: filters.runtimeId.trim(),
worker_id: filters.workerId.trim(),
};
}
return null;
}
// Every page repeats the same server-side filters. Never search a loaded slice.
export function ticketQueryRequest(
filters: TicketFilters,
states: TicketState[],
cursor: string | null = null,
handlingStatus: TicketAssignmentTrackingStatus | null = null,
): TicketQueryRequest {
return {
query: filters.query.trim() || null,
states,
...(filters.closureReason === "all" ? {} : {
closure_reason: filters.closureReason === "other"
? { other: filters.closureText.trim() }
: filters.closureReason,
}),
tag_ids: filters.tagIds,
assigned_principal: filterPrincipal(filters),
handling_status: handlingStatus,
sort: "updated_desc",
limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"],
cursor,
};
}
export function relationLabel(kind: string): string {
return kind.replaceAll("_", " ");
}
@@ -0,0 +1,85 @@
<script lang="ts">
import { goto } from "$app/navigation";
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 { ticketHref } from "#lib/workspace/resource-links.ts";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
let response = $state(untrack(() => data.tickets));
let busy = $state(false);
$effect(() => { const latest = data.tickets; untrack(() => { response = latest; }); });
async function applyFilters(event: SubmitEvent) {
event.preventDefault();
const search = new URLSearchParams();
for (const [key, value] of new FormData(event.currentTarget as HTMLFormElement)) if (typeof value === "string" && value) search.append(key, value);
busy = true;
try { await goto(`?${search}`); } finally { busy = false; }
}
async function loadMore() {
if (busy || (response.data && !response.data.page.has_more)) return;
busy = true;
const observedData = data;
const previous = response.data;
const result = await loadJson(fetch, workspaceApiPath(data.workspaceId, "/tickets/query"), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(ticketQueryRequest(data.filters, data.filters.caseState === "all" ? [] : [data.filters.caseState], previous?.page.next_cursor ?? null, data.handling)),
}, parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY);
if (data !== observedData) { busy = false; return; }
if (result.data && previous) {
const items = new Map(previous.items.map((item) => [item.id, item]));
for (const item of result.data.items) items.set(item.id, item);
response = { data: { ...result.data, items: [...items.values()] }, error: null };
} else response = { data: result.data ?? previous, error: result.error };
busy = false;
}
</script>
<svelte:head><title>Orchestrator · {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}>
<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">
{#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>
<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}
<span>Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
{#if row?.updated_at}<time datetime={row.updated_at}>Handling updated: {new Date(row.updated_at).toLocaleString()}</time>{/if}
<span class="assignment-detail-link">View Ticket, handling history and delivery evidence →</span>
</a>
{:else}
{#if !response.error}<p class="workspace-empty-copy">No current assignments match these filters.</p>{/if}
{/each}
{#if response.error}<div class="workspace-callout is-error" role="alert"><p>{response.error}</p><button class="workspace-secondary-button" type="button" onclick={loadMore} disabled={busy}>Retry query</button></div>{/if}
{#if busy}<p aria-live="polite">Loading assignments…</p>{:else if response.data?.page.has_more && !response.error}<button class="workspace-secondary-button" type="button" onclick={loadMore}>Load more assignments</button>{/if}
</section>
</div>
<style>
.orchestrator-page { max-width: 72rem; }
.workspace-page-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.orchestrator-filters { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
.orchestrator-filters label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 12rem; }
input, select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.orchestrator-assignments { display: grid; }
.orchestrator-assignment { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.orchestrator-assignment:hover { background: var(--interactive-hover); }
.assignment-heading, .assignment-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.assignment-heading strong { color: var(--text-strong); }
.assignment-meta, time { color: var(--text-muted); font-size: var(--font-size-compact); }
.orchestrator-assignment p { margin: 0; white-space: pre-wrap; }
.assignment-detail-link { color: var(--accent); }
</style>
@@ -0,0 +1,55 @@
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
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,
ticketQueryRequest,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { TicketAssignmentTrackingStatus } from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId;
const filters = {
...readTicketFilters(url.searchParams),
principal: "orchestrator" as const,
tagIds: [],
closureReason: "all" as const,
closureText: "",
};
const input = url.searchParams.get("handling");
const handling =
HANDLING_STATES.includes(input as TicketAssignmentTrackingStatus)
? input as TicketAssignmentTrackingStatus
: null;
const request = ticketQueryRequest(
filters,
filters.caseState === "all" ? [] : [filters.caseState],
null,
handling,
);
const [tickets, orchestrator] = await Promise.all([
loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
return { workspaceId, filters, handling, tickets, orchestrator };
};
@@ -11,27 +11,28 @@
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketListResponse,
parseTicketQueryResponse,
parseTicketRecordRef,
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
QueryPage,
TicketListItemSummary as TicketSummary,
TicketQueryItem as TicketSummary,
TicketState,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
ticketLanes,
ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageData } from "./$types";
type LaneState = {
states: string[];
states: TicketState[];
tickets: TicketSummary[];
page: QueryPage;
page: QueryPage | null;
loading: boolean;
error: string | null;
};
@@ -43,21 +44,19 @@
};
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
Object.fromEntries(
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
laneId,
{
states: [...lane.states],
tickets: lane.response.items,
page: lane.response.page,
loading: false,
error: null,
},
]),
),
);
function initialLanes(): Record<string, LaneState> {
return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, {
states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null,
loading: false, error: result.error,
}]] : []));
}
let laneState = $state<Record<string, LaneState>>(untrack(initialLanes));
let principal = $state(untrack(() => data.filters.principal));
let closureChoice = $state(untrack(() => data.filters.closureReason));
let filtersBusy = $state(false);
$effect(() => { const currentData = data; untrack(() => {
laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator;
}); });
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
@@ -81,10 +80,16 @@
}
return true;
});
const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
const lanes = $derived(ticketLanes(tickets));
const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets })));
async function applyFilters(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const search = new URLSearchParams();
for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value);
filtersBusy = true;
try { await goto(`?${search}`); } finally { filtersBusy = false; }
}
function mergeTickets(
current: TicketSummary[],
@@ -97,22 +102,17 @@
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
if (!lane || lane.loading || (lane.page && !lane.page.has_more)) {
return;
}
lane.loading = true;
lane.error = null;
try {
const search = new URLSearchParams({
limit: "30",
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const result = await loadJson(
fetch,
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
workspaceApiPath(data.workspaceId, "/tickets/query"),
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) },
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
@@ -127,13 +127,6 @@
}
}
function handleLaneScroll(event: Event, laneId: string): void {
const container = event.currentTarget as HTMLElement;
const remaining =
container.scrollHeight - container.scrollTop - container.clientHeight;
if (remaining <= 96) void loadMore(laneId);
}
function addCreateTarget(): void {
createTargets.push({
repository_key: "",
@@ -227,9 +220,9 @@
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data?.online ? "Online" : "Offline"}</span>
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if !orchestrator.data?.online}
{#if orchestrator.data && !orchestrator.data.online}
<button
class="workspace-secondary-button"
type="button"
@@ -240,10 +233,6 @@
</button>
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>loaded tickets</span>
</div>
</div>
</header>
@@ -295,13 +284,30 @@
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
</p>
{:else if !orchestrator.data?.online}
<p class="workspace-callout">
Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly.
</p>
{/if}
<section class="ticket-kanban" aria-label="Ticket workflow board">
<form class="ticket-query-form" onsubmit={applyFilters} aria-label="Ticket filters">
<label>Search<input name="q" value={data.filters.query} type="search" /></label>
<label>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>
<label>Assigned principal<select name="principal" bind:value={principal}><option value="all">Any</option><option value="orchestrator">Orchestrator</option><option value="user">User</option><option value="worker">Worker</option></select></label>
{#if principal === "user"}<label>Account ID<input name="account" value={data.filters.accountId} required /></label>{/if}
{#if principal === "worker"}<label>Runtime ID<input name="runtime" value={data.filters.runtimeId} required /></label><label>Worker ID<input name="worker" value={data.filters.workerId} required /></label>{/if}
<fieldset><legend>Classification tags (all selected)</legend>
{#each data.tags.data ?? [] as tag (tag.id)}
{#if !tag.deleted}<label class="ticket-tag-filter"><input type="checkbox" name="tag" value={tag.id} checked={data.filters.tagIds.includes(tag.id)} />{tag.name}</label>{/if}
{:else}<span class="workspace-empty-copy">No tags</span>{/each}
{#if data.tags.error}<p class="workspace-callout is-error" role="alert">Tags: {data.tags.error}</p>{/if}
</fieldset>
<details class="ticket-reason-filter" open={data.filters.closureReason !== "all"}><summary>Close reason filter</summary>
<label>Current close reason<select name="reason" bind:value={closureChoice}><option value="all">Any reason</option><option value="completed">Completed</option><option value="not_planned">Not planned</option><option value="duplicate">Duplicate</option><option value="unknown">Unknown</option><option value="other">Other (exact text)</option></select></label>
{#if closureChoice === "other"}<label>Close reason text<input name="reason_text" value={data.filters.closureText} required /></label>{/if}
<p class="workspace-empty-copy">Matches current closed cases. Search also includes past close reasons.</p>
</details>
<button class="workspace-secondary-button" disabled={filtersBusy}>{filtersBusy ? "Searching…" : "Apply filters"}</button>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/orchestrator`}>Orchestrator handling</a>
</form>
<section class="ticket-kanban" aria-label="Ticket cases">
{#each lanes as lane (lane.id)}
{@const pagination = laneState[lane.id]}
<section class="ticket-lane" data-state={lane.id}>
@@ -310,14 +316,14 @@
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">{lane.tickets.length}</span>
<span class="ticket-lane-count">{lane.tickets.length} loaded</span>
</header>
<div
class="ticket-lane-cards"
data-lane-id={lane.id}
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each lane.tickets as ticket (ticket.id)}
{@const assignment = currentOrchestratorAssignment(ticket)}
<a
class="ticket-card"
href={ticketHref(data.workspaceId, ticket)}
@@ -325,12 +331,19 @@
<span class="ticket-card-id">{ticket.resource_key}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span>
<span>Case: {ticket.state} · {ticket.priority}</span>
<time>{prettyDate(ticket.updated_at)}</time>
</div>
{#if ticket.current_closure}<span class="ticket-closure-summary">Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
<span class="ticket-classification">Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
<div class="ticket-handling-summary">
<span>Orchestrator handling: {assignment ? handlingLabel(assignment.tracking?.status) : "Not assigned"}{#if assignment}{" "}<small>({assignment.tracking ? "self-reported" : "not reported"})</small>{/if}</span>
{#if assignment}<span>Assigned principal: {assignmentPrincipalLabel(assignment.principal)}</span>{/if}
<span>{handlingSummary(assignment)}</span>
</div>
</a>
{:else}
<div class="ticket-lane-empty">No tickets</div>
{#if !pagination?.error}<div class="ticket-lane-empty">No tickets</div>{/if}
{/each}
{#if pagination?.loading}
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
@@ -339,7 +352,9 @@
<span>{pagination.error}</span>
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
</div>
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
{:else if pagination?.page?.has_more}
<button class="workspace-secondary-button" type="button" onclick={() => loadMore(lane.id)}>Load more {lane.label.toLowerCase()} tickets</button>
{:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if}
</div>
@@ -347,3 +362,22 @@
{/each}
</section>
</div>
<style>
.ticket-query-form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
.ticket-query-form > label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 10rem; }
.ticket-query-form input:not([type="checkbox"]), .ticket-query-form select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.ticket-query-form fieldset { display: flex; flex-wrap: wrap; gap: var(--space-3); border: 0; padding: 0; flex: 1 1 100%; min-width: 0; }
.ticket-tag-filter { display: flex; align-items: center; gap: var(--space-1); overflow-wrap: anywhere; }
.ticket-reason-filter { flex: 1 1 100%; }
.ticket-reason-filter label { display: grid; gap: var(--space-1); max-width: 28rem; margin-block: var(--space-2); }
.ticket-reason-filter summary { cursor: pointer; }
.ticket-query-form legend { margin-bottom: var(--space-2); }
.ticket-kanban { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); align-items: start; overflow: visible; }
.ticket-lane { height: auto; min-height: 0; max-height: none; }
.ticket-lane-cards { max-height: none; overflow: visible; }
.ticket-card { overflow-wrap: anywhere; }
.ticket-handling-summary { display: grid; gap: var(--space-1); border-top: 1px solid var(--line); padding-top: var(--space-2); color: var(--text-muted); }
.ticket-classification { color: var(--text-muted); }
.ticket-closure-summary { font-weight: 600; }
</style>
@@ -2,56 +2,52 @@ import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/wo
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseTicketListResponse,
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import type { TicketListResponse } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
import {
readTicketFilters,
ticketQueryRequest,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { TicketState } from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
const LANE_STATES = { open: ["open"], closed: ["closed"] } as const;
export type TicketLaneId = keyof typeof LANE_STATES;
export type TicketLanePage = {
states: readonly string[];
response: TicketListResponse;
};
async function loadLane(
fetchFn: typeof fetch,
workspaceId: string,
laneId: TicketLaneId,
): Promise<TicketLanePage> {
const states = LANE_STATES[laneId];
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const result = await loadJson(
fetchFn,
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`);
}
return { states: [...states], response: result.data };
}
export const load: PageLoad = async ({ fetch, params }) => {
export const load: PageLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId;
const [
open,
closed,
orchestrator,
repositories,
] = await Promise.all([
loadLane(fetch, workspaceId, "open"),
loadLane(fetch, workspaceId, "closed"),
loadJson<WorkspaceOrchestratorStatus>(
const filters = readTicketFilters(url.searchParams);
const loadLane = async (state: TicketState) => {
if (filters.caseState !== "all" && filters.caseState !== state) return null;
try {
return await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketQueryRequest(filters, [state])),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : String(error),
};
}
};
const [open, closed, tags, orchestrator, repositories] = await Promise.all([
loadLane("open"),
loadLane("closed"),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/ticket-tags"),
undefined,
parseTags,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
@@ -59,13 +55,11 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return {
workspaceId,
ticketLanes: {
open,
closed,
},
filters,
ticketLanes: { open, closed },
tags,
orchestrator,
repositories,
};
@@ -1,5 +1,9 @@
<script lang="ts">
import { untrack } from "svelte";
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJson,
@@ -8,11 +12,11 @@
} from "#lib/workspace/api/http.ts";
import {
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketSummary,
parseTicketDetail,
parseTicketQueueOutcome,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts";
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
import {
@@ -86,8 +90,8 @@
let threadBody = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let queueMessage = $state<string | null>(null);
let readyOperationKey = $state<string | null>(null);
let caseOperation = $state<ObservedOperation | null>(null);
let caseSaved = $state(false);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
@@ -151,8 +155,8 @@
threadBody = "";
busy = null;
errorMessage = null;
queueMessage = null;
readyOperationKey = null;
caseOperation = null;
caseSaved = false;
manualRuntimeId = "";
manualWorkerId = "";
manualBindings = [];
@@ -226,36 +230,11 @@
}
}
async function queueTicket(): Promise<void> {
if (busy) return;
async function refreshOperationTicket(): Promise<void> {
const generation = routeGeneration;
const path = ticketPath;
busy = "queue";
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody(
`${path}/queue`,
{ method: "POST", body: JSON.stringify({}) },
parseTicketQueueOutcome,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(updatedTicket);
} catch (error) {
if (generation === routeGeneration) {
errorMessage = ticketMutationError(error);
}
} finally {
if (generation === routeGeneration) busy = null;
}
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
}
async function mutateAssignment(
@@ -276,7 +255,7 @@
body: JSON.stringify({
operation_id: crypto.randomUUID(),
principal,
expected_assignment_id: null,
expected_assignment_id: ticket.assignments.find((row) => row.role === role)?.assignment_id ?? null,
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
}),
},
@@ -300,13 +279,6 @@
}
}
async function assignOrchestrator(): Promise<void> {
await mutateAssignment("assign-orchestrator", "orchestrator", {
kind: "workspace_agent",
agent_key: "workspace-orchestrator",
});
}
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
@@ -361,34 +333,30 @@
await mutate("target", "", targetEditBody(), "PATCH");
}
async function markReady() {
if (ticket.state !== "open" || busy) return;
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
transitionReason = "";
}
}
function selectedClosureReason(): TicketClosureReason {
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || !transitionReason.trim()) return;
if (busy || caseOperation || !transitionReason.trim()) return;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
await runCaseDecision(observeOperation(path, "POST", request));
}
async function runCaseDecision(operation: ObservedOperation): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
busy = action.action;
busy = "case";
caseOperation = operation;
errorMessage = null;
caseSaved = false;
try {
await updateTicketCase(data.workspaceId, data.ticketId, request);
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
caseOperation = null;
caseSaved = true;
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
@@ -432,9 +400,11 @@
<header class="ticket-detail-header">
<div>
<div class="ticket-detail-kicker">
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
<span>{ticket.resource_key}</span>
<span class="workspace-status-pill" data-status={ticket.state}>Case: {ticket.state}</span>
</div>
<h1>{ticket.title}</h1>
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
@@ -446,9 +416,6 @@
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
{/if}
{#if queueMessage}
<div class="workspace-callout" role="status">{queueMessage}</div>
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
@@ -471,6 +438,12 @@
{/if}
</section>
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
{/key}
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Relations</h2>
@@ -481,7 +454,7 @@
{#each ticket.relations.blockers as blocker}
{#if blocker.blocking_resource_key}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
<strong>Blocked by {blocker.blocking_resource_key}</strong>
<strong>Dependency {blocker.blocking_resource_key} remains unresolved</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{:else}
@@ -550,9 +523,15 @@
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<section class="ticket-control-card ticket-case-card">
<header><h2>Case decision</h2></header>
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
{#if caseOperation}
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
{/if}
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
<label>Closure reason
@@ -565,18 +544,13 @@
</select>
</label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#if ticket.state === "open"}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button>
{/if}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
@@ -603,16 +577,6 @@
{:else}
<p class="workspace-empty-copy">No role responsibility recorded.</p>
{/if}
{#if ticket.action_eligibility.can_assign_orchestrator}
<button
class="workspace-primary-button"
type="button"
disabled={busy !== null}
onclick={assignOrchestrator}
>
{busy === "assign-orchestrator" ? "Assigning…" : "Assign Orchestrator"}
</button>
{/if}
{#if implementationStartEligible}
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
@@ -25,12 +25,25 @@ Deno.test("ticket detail synchronizes reused route data", () => {
}
});
Deno.test("ticket child operations reset across Workspaces even when the retained Ticket id matches", () => {
assertStringIncludes(
pageSource,
"{#key JSON.stringify([data.workspaceId, ticket.id])}",
);
const children = pageSource.slice(
pageSource.indexOf("{#key JSON.stringify([data.workspaceId, ticket.id])}"),
pageSource.indexOf("{/key}"),
);
assertStringIncludes(children, "<TicketTags");
assertStringIncludes(children, "<TicketHandling");
assertStringIncludes(children, "<TicketWorkerObservation");
});
Deno.test("ticket detail fences stale mutation responses", () => {
for (
const operation of [
"async function mutate(",
"async function queueTicket(",
"async function caseDecision(",
"async function runCaseDecision(",
"async function mutateAssignment(",
]
) {