chore: merge develop preserving documentation structure and console UX

Integrate develop at 523bade3 while retaining concise agent guidance, rustdoc-owned design rationale, and current console history/rewind behavior. Adapt incoming tests to public entry-ID rewind and synchronize OpenAPI/TypeScript artifacts.

Validation: default-member cargo test --all-features (4936 passed, 23 ignored), root cargo check, all-feature rustdoc with broken links denied, format/diff/name guards, OpenAPI and 11 TypeScript freshness checks. Web: 613 unit, 327 component, 7 Ticket browser and 3 Console browser tests; check/build passed. Merged-state Ticket visual review at 1440/768/390px in light/dark; no matching pre-merge image baseline. E2E/live-provider checks not run.
This commit is contained in:
2026-10-13 02:46:50 +09:00
213 changed files with 59018 additions and 14443 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/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:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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
@@ -151,7 +151,7 @@ export type RuntimeWorkerSpawnWorkingDirectoryRequest = { repository_key: string
export type RuntimeWorkerSummary = { diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<unknown>; worker_id: string; worker_state?: unknown; workspace: WorkerWorkspaceSummary; };
export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
+76 -12
View File
@@ -1,26 +1,26 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
export type Array_of_TicketAssignmentTrackingProjection = Array<TicketAssignmentTrackingProjection>;
export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
export type Array_of_WorkspaceTag = Array<WorkspaceTag>;
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
export type BrowserQueueTicketRequest = { [key: string]: never; };
export type BrowserTicketState = "open" | "closed";
export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; };
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
export type ConflictResolution = "none" | "clean" | "conflicts_resolved";
@@ -29,6 +29,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExtensibleTicketStatus = "Open" | "Closed" | { Other: string; };
export type FindingSeverity = "blocker" | "major" | "minor" | "note";
export type InvalidProjectRecord = { label: string; reason: string; };
@@ -57,7 +59,7 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string
export type MergeStrategy = "fast_forward" | "merge";
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; };
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; };
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; };
@@ -81,17 +83,61 @@ export type ReviewDecision = "approve" | "request_changes";
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
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"; };
export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; };
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 TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; };
export type TicketAssignmentTrackingHistoryEntry = { at: string; event_id: string; operation_key: string; source_context: unknown; tracking: TicketAssignmentTracking; };
export type TicketAssignmentTrackingHistoryPage = { items: Array<TicketAssignmentTrackingHistoryEntry>; next_event_id?: string | null; };
export type TicketAssignmentTrackingProjection = { assigned_at: string; assigned_by: string; assignment_id: string; current: boolean; last_event_id?: string | null; migration_diagnostic?: string | null; principal: TicketAssignmentPrincipalSnapshot; release?: TicketAssignmentRelease | null; role: string; ticket_id: string; tracking?: TicketAssignmentTracking | null; updated_at?: string | null; workspace_id: string; };
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; };
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; };
export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; };
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -111,7 +157,9 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; };
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workspace_action_priority: string; };
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
@@ -119,6 +167,12 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
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 = { 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; };
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; };
@@ -133,12 +187,22 @@ export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: st
export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; };
export type TicketState = "open" | "closed";
export type TicketStatus = "Open" | "Closed";
export type TicketSummary = { id: string; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; };
export type TicketTagSetRequest = { expected_tag_ids: Array<string>; operation_key: string; tag_ids: Array<string>; };
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
export type TicketTargetAccess = "read_only" | "read_write";
export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; };
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
export type WorkspaceTag = { deleted: boolean; id: string; last_operation_key: string; name: string; workspace_id: string; };
export type WorkspaceTagAction = { kind: "create"; name: string; } | { expected_last_operation_key: string; kind: "rename"; name: string; tag_id: string; } | { expected_last_operation_key: string; kind: "delete"; tag_id: string; };
export type WorkspaceTagMutation = { action: WorkspaceTagAction; operation_key: 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238)
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// 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
@@ -13,7 +13,7 @@ export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnost
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
@@ -10,6 +10,7 @@ import {
parseTicketListResponse,
parseTicketRecordRef,
parseTicketRoleAssignmentMutationResponse,
parseTicketSummary,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
@@ -48,7 +49,8 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () =>
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "ready",
tags: [],
state: "open",
priority: "P2",
updated_at: null,
queued_by: null,
@@ -105,16 +107,23 @@ Deno.test("Ticket Browser accepts generic Worker eligibility and assignments", (
assigned_at: "2026-10-06T00:00:00Z",
assigned_by: "user-1",
assignment_id: "assignment-1",
principal: { kind: "worker", runtime_id: "runtime-1", worker_id: "worker-1" },
principal: {
kind: "worker",
runtime_id: "runtime-1",
worker_id: "worker-1",
},
role: "worker",
ticket_id: "ticket-id",
workspace_id: "workspace-a",
};
assertEquals(parseTicketRoleAssignmentMutationResponse({
assertEquals(
parseTicketRoleAssignmentMutationResponse({
assignment,
ticket_id: "ticket-id",
workspace_id: "workspace-a",
}).assignment,
assignment,
ticket_id: "ticket-id",
workspace_id: "workspace-a",
}).assignment, assignment);
);
});
function ticketDetailFixture() {
@@ -130,7 +139,12 @@ function ticketDetailFixture() {
artifact_count: 0,
artifacts: [],
assignment_diagnostics: [],
assignment_tracking: [],
assignments: [],
closure_history: [],
closure_operation_key: null,
current_closure: null,
legacy_handoff: null,
body: "Body",
body_truncated: false,
current_worker: null,
@@ -164,7 +178,8 @@ function ticketDetailFixture() {
resolution: null,
resource_key: "T-1",
risk_flags: [],
state: "planning",
state: "open",
tags: [],
targets: [
{
repository_key: "docs",
@@ -181,6 +196,52 @@ function ticketDetailFixture() {
};
}
Deno.test("Ticket Browser keeps self-reported delegation independent of case and Worker state", () => {
const tracking = {
workspace_id: "workspace-a",
ticket_id: "ticket-id",
assignment_id: "delegation",
role: "orchestrator",
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
assigned_by: "workspace-user:alice",
assigned_at: "2026-01-01T00:00:00Z",
release: null,
current: true,
last_event_id: "report-event",
updated_at: null,
migration_diagnostic: null,
tracking: {
status: "done",
waiting_reason: null,
progress: "Investigated",
next_action: "Human decision",
result: "Alternatives returned",
},
};
for (const state of ["open", "closed"]) {
const parsed = parseTicketDetail({
...ticketDetailFixture(),
state,
assignment_tracking: [tracking],
});
assertEquals(parsed.assignment_tracking[0]?.tracking?.status, "done");
assertEquals(parsed.assignment_tracking[0]?.last_event_id, "report-event");
assertEquals(parsed.state, state);
assertEquals(parsed.current_worker, null);
}
assertThrows(
() =>
parseTicketDetail({
...ticketDetailFixture(),
assignment_tracking: [{
...tracking,
tracking: { ...tracking.tracking, status: "running" },
}],
}),
".status is invalid",
);
});
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
const fixture = ticketDetailFixture();
const parsed = parseTicketDetail(fixture);
@@ -342,11 +403,11 @@ Deno.test("Merge Request summaries require a nullable bounded integration eviden
}
});
Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => {
Deno.test("closed Ticket state is preserved when current requirement evidence is incomplete", () => {
const fixture = ticketDetailFixture();
const parsed = parseTicketDetail({
...fixture,
state: "done",
state: "closed",
evidence: { ...fixture.evidence, missing: ["review_after_rescope"] },
merge_requests: [{
...mergeRequestSummary,
@@ -354,7 +415,7 @@ Deno.test("done Ticket state is preserved when current requirement evidence is i
source_ref_observation: { status: "not_required" },
}],
});
assertEquals(parsed.state, "done");
assertEquals(parsed.state, "closed");
assertEquals(parsed.evidence.complete_for_integration, false);
assertEquals(parsed.merge_requests[0]?.review_status, "approved");
});
@@ -440,3 +501,213 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
"unknown field unexpected",
);
});
const tags = ["ready", "closed"].map((name) => ({
workspace_id: "workspace-a",
id: `tag-${name}`,
name,
last_operation_key: `create-${name}`,
deleted: false,
}));
const closure = {
operation_key: "close-1",
reason: "not_planned",
explanation: "Not planned for this scope.",
references: [{ kind: "document", target: "result.md" }],
author: "user:alice",
at: "2026-10-10T00:00:00Z",
};
const closureHistory = [
{
...closure,
reason: undefined,
previous_closure_operation_key: null,
action: { action: "close", reason: "not_planned" },
},
{
...closure,
reason: undefined,
operation_key: "correct-1",
previous_closure_operation_key: "close-1",
action: { action: "correct_closure", reason: { other: "Deferred" } },
},
{
...closure,
reason: undefined,
operation_key: "reopen-1",
previous_closure_operation_key: "correct-1",
action: { action: "reopen" },
},
].map(({ reason: _, ...entry }) => ({
operation_key: entry.operation_key,
previous_closure_operation_key: entry.previous_closure_operation_key,
action: entry.action,
explanation: entry.explanation,
references: entry.references,
author: entry.author,
at: entry.at,
}));
const legacyHandoff = {
workflow_state: "done",
workflow_state_explicit: true,
status: "Closed",
queued_by: "workspace-web",
queued_at: "2026-10-09T00:00:00Z",
resolution: "Historical result, not a current case state",
};
Deno.test("tags named ready and closed coexist with case judgment and retained reopen history without inferring state", () => {
const fixture = {
...ticketDetailFixture(),
state: "open",
tags,
closure_operation_key: "correct-1",
current_closure: null,
closure_history: closureHistory,
legacy_handoff: legacyHandoff,
};
const parsed = parseTicketDetail(fixture);
assertEquals(parsed.state, "open");
assertEquals(parsed.closure_operation_key, "correct-1");
assertEquals(parsed.closure_history, closureHistory);
assertEquals(parsed.legacy_handoff, legacyHandoff);
assertEquals(parsed.tags, tags);
const closed = parseTicketDetail({
...fixture,
state: "closed",
current_closure: closure,
});
assertEquals(closed.state, "closed");
assertEquals(closed.current_closure, closure);
assertEquals(closed.closure_history, closureHistory);
assertEquals(closed.tags, tags);
const list = parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
page,
invalid_records: [],
record_authority: "sqlite",
items: [{
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "closed",
tags,
priority: "P2",
record_source: "sqlite",
workspace_action_priority: "background",
closure_operation_key: "close-1",
current_closure: closure,
legacy_handoff: legacyHandoff,
}],
});
assertEquals(list.items[0].state, "closed");
assertEquals(list.items[0].tags, tags);
assertEquals(list.items[0].current_closure, closure);
assertEquals(list.items[0].legacy_handoff, legacyHandoff);
});
Deno.test("Ticket tag projections reject missing collections and malformed identities, types and unknown fields", () => {
const parsers = [
(tags: unknown) => parseTicketDetail({ ...ticketDetailFixture(), tags }),
(tags: unknown) =>
parseTicketSummary({
id: "ticket-id",
slug: "ticket",
title: "Ticket",
kind: "task",
labels: [],
priority: "P2",
status: "Open",
workflow_state: "open",
workflow_state_explicit: true,
tags,
}),
(tags: unknown) =>
parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
page,
invalid_records: [],
record_authority: "sqlite",
items: [{
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "open",
priority: "P2",
record_source: "sqlite",
workspace_action_priority: "background",
tags,
}],
}),
];
const tag = tags[0];
for (const parse of parsers) {
for (const value of [undefined, null, {}, "ready"]) {
assertThrows(() => parse(value), "tags must be an array");
}
for (
const [value, message] of [
[{ ...tag, id: undefined }, "id must be a string"],
[{ ...tag, id: 1 }, "id must be a string"],
[{ ...tag, id: " " }, "id must be nonempty"],
[{ ...tag, id: "é".repeat(65) }, "at most 128 UTF-8 bytes"],
[{ ...tag, workspace_id: false }, "workspace_id must be a string"],
[{ ...tag, name: null }, "name must be a string"],
[
{ ...tag, last_operation_key: 1 },
"last_operation_key must be a string",
],
[{ ...tag, deleted: "false" }, "deleted must be a boolean"],
[{ ...tag, state: "closed" }, "unknown field state"],
] as const
) {
assertThrows(() => parse([value]), message);
}
}
});
Deno.test("case decoders reject legacy current states and malformed closure judgments", () => {
const fixture = ticketDetailFixture();
for (const state of ["planning", "ready", "queued", "inprogress", "done"]) {
assertThrows(
() => parseTicketDetail({ ...fixture, state }),
"state is invalid",
);
}
for (const reason of ["done", { other: 1 }, { other: "ok", extra: true }]) {
assertThrows(
() =>
parseTicketDetail({
...fixture,
current_closure: { ...closure, reason },
}),
"Ticket detail.current_closure.reason",
);
}
for (
const action of ["reopen", { action: "close" }, {
action: "reopen",
reason: "completed",
}, { action: "complete", reason: "completed" }]
) {
assertThrows(
() =>
parseTicketDetail({
...fixture,
closure_history: [{ ...closureHistory[0], action }],
}),
"Ticket detail.closure_history[0].action",
);
}
assertThrows(
() => parseTicketDetail({ ...fixture, closure_history: {} }),
"closure_history must be an array",
);
assertThrows(
() => parseTicketDetail({ ...fixture, closure_operation_key: 1 }),
"closure_operation_key must be a string",
);
});
@@ -1,4 +1,5 @@
import type {
BrowserTicketState,
InvalidProjectRecord,
MergeRequestDetailResponse,
MergeRequestLinkedTicketResponse,
@@ -19,7 +20,14 @@ import type {
ReviewFinding,
TicketActionEligibility,
TicketAssignmentPrincipal,
TicketAssignmentRelease,
TicketAssignmentSummary,
TicketAssignmentTracking,
TicketAssignmentTrackingProjection,
TicketCaseAction,
TicketCaseHistoryEntry,
TicketClosureJudgment,
TicketClosureReason,
TicketDetail,
TicketDetailDerivedRelation,
TicketDetailRelation,
@@ -29,15 +37,21 @@ import type {
TicketEventDetail,
TicketEvidenceEvent,
TicketEvidenceSummary,
TicketLegacyWorkflowSnapshot,
TicketListItemSummary,
TicketListResponse,
TicketMergeRequestSummary,
TicketQueryItem,
TicketQueryResponse,
TicketQueueOutcome,
TicketRef,
TicketReference,
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentRecord,
TicketRoleAssignmentSummary,
TicketSummary,
TicketTarget,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -183,12 +197,238 @@ function parseInvalidProjectRecord(
};
}
function parseTicketState(value: unknown, label: string): BrowserTicketState {
if (value !== "open" && value !== "closed") {
throw new Error(`${label} is invalid`);
}
return value;
}
function parseClosureReason(
value: unknown,
label: string,
): TicketClosureReason {
if (
typeof value === "string" &&
["completed", "not_planned", "duplicate", "unknown"].includes(value)
) {
return value as TicketClosureReason;
}
const item = object(value, label);
exact(item, ["other"], label);
return { other: string(item.other, `${label}.other`) };
}
function parseCaseAction(value: unknown, label: string): TicketCaseAction {
const item = object(value, label);
if (item.action === "reopen") {
exact(item, ["action"], label);
return { action: "reopen" };
}
if (item.action !== "close" && item.action !== "correct_closure") {
throw new Error(`${label}.action is invalid`);
}
exact(item, ["action", "reason"], label);
return {
action: item.action,
reason: parseClosureReason(item.reason, `${label}.reason`),
};
}
function parseReference(value: unknown, label: string): TicketReference {
const item = object(value, label);
exact(item, ["kind", "target"], label);
return {
kind: string(item.kind, `${label}.kind`),
target: string(item.target, `${label}.target`),
};
}
function parseClosure(value: unknown, label: string): TicketClosureJudgment {
const item = object(value, label);
exact(item, [
"operation_key",
"reason",
"explanation",
"references",
"author",
"at",
], label);
return {
operation_key: string(item.operation_key, `${label}.operation_key`),
reason: parseClosureReason(item.reason, `${label}.reason`),
explanation: string(item.explanation, `${label}.explanation`),
references: array(item.references, `${label}.references`, parseReference),
author: string(item.author, `${label}.author`),
at: string(item.at, `${label}.at`),
};
}
function parseCaseHistory(
value: unknown,
label: string,
): TicketCaseHistoryEntry {
const item = object(value, label);
exact(item, [
"operation_key",
"previous_closure_operation_key",
"action",
"explanation",
"references",
"author",
"at",
], label);
return {
operation_key: string(item.operation_key, `${label}.operation_key`),
previous_closure_operation_key: optionalNullableString(
item.previous_closure_operation_key,
`${label}.previous_closure_operation_key`,
),
action: parseCaseAction(item.action, `${label}.action`),
explanation: string(item.explanation, `${label}.explanation`),
references: array(item.references, `${label}.references`, parseReference),
author: string(item.author, `${label}.author`),
at: string(item.at, `${label}.at`),
};
}
function parseLegacyHandoff(
value: unknown,
label: string,
): TicketLegacyWorkflowSnapshot {
const item = object(value, label);
exact(item, [
"workflow_state",
"workflow_state_explicit",
"status",
"queued_by",
"queued_at",
"resolution",
], label);
return {
workflow_state: string(item.workflow_state, `${label}.workflow_state`),
workflow_state_explicit: boolean(
item.workflow_state_explicit,
`${label}.workflow_state_explicit`,
),
status: string(item.status, `${label}.status`),
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
};
}
function caseProjection(item: Record<string, unknown>, label: string) {
return {
closure_operation_key: optionalNullableString(
item.closure_operation_key,
`${label}.closure_operation_key`,
),
current_closure: item.current_closure === undefined
? undefined
: item.current_closure === null
? null
: parseClosure(item.current_closure, `${label}.current_closure`),
legacy_handoff: item.legacy_handoff === undefined
? undefined
: item.legacy_handoff === null
? null
: parseLegacyHandoff(item.legacy_handoff, `${label}.legacy_handoff`),
};
}
export function parseWorkspaceTag(
value: unknown,
label = "Workspace tag",
): WorkspaceTag {
const item = object(value, label);
exact(item, [
"workspace_id",
"id",
"name",
"last_operation_key",
"deleted",
], label);
const id = string(item.id, `${label}.id`);
if (id.trim().length === 0 || new TextEncoder().encode(id).length > 128) {
throw new Error(`${label}.id must be nonempty and at most 128 UTF-8 bytes`);
}
return {
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
id,
name: string(item.name, `${label}.name`),
last_operation_key: string(
item.last_operation_key,
`${label}.last_operation_key`,
),
deleted: boolean(item.deleted, `${label}.deleted`),
};
}
export function parseTicketSummary(value: unknown): TicketSummary {
const label = "Ticket summary";
const item = object(value, label);
exact(item, [
"id",
"kind",
"labels",
"priority",
"queued_at",
"queued_by",
"readiness",
"resource_key",
"slug",
"status",
"tags",
"title",
"updated_at",
"workflow_state",
"workflow_state_explicit",
], label);
let status: TicketSummary["status"];
if (item.status === "Open" || item.status === "Closed") status = item.status;
else {
const other = object(item.status, `${label}.status`);
exact(other, ["Other"], `${label}.status`);
status = { Other: string(other.Other, `${label}.status.Other`) };
}
return {
id: string(item.id, `${label}.id`),
kind: string(item.kind, `${label}.kind`),
labels: strings(item.labels, `${label}.labels`),
priority: string(item.priority, `${label}.priority`),
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
readiness: optionalNullableString(item.readiness, `${label}.readiness`),
resource_key: optionalNullableString(
item.resource_key,
`${label}.resource_key`,
),
slug: string(item.slug, `${label}.slug`),
status,
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
workflow_state: parseTicketState(
item.workflow_state,
`${label}.workflow_state`,
),
workflow_state_explicit: boolean(
item.workflow_state_explicit,
`${label}.workflow_state_explicit`,
),
};
}
function parseTicketListItem(
value: unknown,
label: string,
): TicketListItemSummary {
const item = object(value, label);
exact(item, [
"closure_operation_key",
"current_closure",
"legacy_handoff",
"id",
"priority",
"queued_at",
@@ -196,18 +436,21 @@ function parseTicketListItem(
"record_source",
"resource_key",
"state",
"tags",
"title",
"updated_at",
"workspace_action_priority",
], label);
return {
...caseProjection(item, label),
id: string(item.id, `${label}.id`),
priority: string(item.priority, `${label}.priority`),
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
record_source: string(item.record_source, `${label}.record_source`),
resource_key: string(item.resource_key, `${label}.resource_key`),
state: string(item.state, `${label}.state`),
state: parseTicketState(item.state, `${label}.state`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
workspace_action_priority: string(
@@ -414,6 +657,130 @@ function parseTicketRoleAssignmentRecord(
};
}
export function parseAssignmentTracking(
value: unknown,
label: string,
): TicketAssignmentTracking {
const item = object(value, label);
exact(
item,
["status", "waiting_reason", "progress", "next_action", "result"],
label,
);
const status = string(item.status, `${label}.status`);
if (status !== "waiting" && status !== "inprogress" && status !== "done") {
throw new Error(`${label}.status is invalid`);
}
return {
status,
waiting_reason: optionalNullableString(
item.waiting_reason,
`${label}.waiting_reason`,
),
progress: optionalNullableString(item.progress, `${label}.progress`),
next_action: optionalNullableString(
item.next_action,
`${label}.next_action`,
),
result: optionalNullableString(item.result, `${label}.result`),
};
}
function parseAssignmentRelease(
value: unknown,
label: string,
): TicketAssignmentRelease {
const item = object(value, label);
exact(item, [
"action",
"event_id",
"operation_key",
"actor",
"at",
"reason",
"replacement_assignment_id",
"last_tracking",
], label);
const action = string(item.action, `${label}.action`);
if (action !== "unassigned" && action !== "replaced") {
throw new Error(`${label}.action is invalid`);
}
return {
action,
event_id: string(item.event_id, `${label}.event_id`),
operation_key: optionalNullableString(
item.operation_key,
`${label}.operation_key`,
),
actor: string(item.actor, `${label}.actor`),
at: string(item.at, `${label}.at`),
reason: optionalNullableString(item.reason, `${label}.reason`),
replacement_assignment_id: optionalNullableString(
item.replacement_assignment_id,
`${label}.replacement_assignment_id`,
),
last_tracking: item.last_tracking === undefined
? undefined
: item.last_tracking === null
? null
: parseAssignmentTracking(item.last_tracking, `${label}.last_tracking`),
};
}
export function parseAssignmentTrackingProjection(
value: unknown,
label: string,
): TicketAssignmentTrackingProjection {
const item = object(value, label);
exact(item, [
"workspace_id",
"ticket_id",
"assignment_id",
"role",
"principal",
"assigned_by",
"assigned_at",
"release",
"current",
"tracking",
"last_event_id",
"updated_at",
"migration_diagnostic",
], label);
return {
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
ticket_id: string(item.ticket_id, `${label}.ticket_id`),
assignment_id: string(item.assignment_id, `${label}.assignment_id`),
role: string(item.role, `${label}.role`),
principal: parseTicketAssignmentPrincipal(
item.principal,
`${label}.principal`,
),
assigned_by: string(item.assigned_by, `${label}.assigned_by`),
assigned_at: string(item.assigned_at, `${label}.assigned_at`),
release: item.release === undefined
? undefined
: item.release === null
? null
: parseAssignmentRelease(item.release, `${label}.release`),
current: boolean(item.current, `${label}.current`),
tracking: item.tracking === undefined
? undefined
: item.tracking === null
? null
: parseAssignmentTracking(item.tracking, `${label}.tracking`),
last_event_id: optionalNullableString(
item.last_event_id,
`${label}.last_event_id`,
),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
migration_diagnostic: optionalNullableString(
item.migration_diagnostic,
`${label}.migration_diagnostic`,
),
};
}
function parseActionEligibility(
value: unknown,
label: string,
@@ -726,7 +1093,9 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"artifact_count",
"artifacts",
"assignment_diagnostics",
"assignment_tracking",
"assignments",
"closure_history",
"body",
"body_truncated",
"created_at",
@@ -735,6 +1104,9 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"event_page",
"events",
"evidence",
"closure_operation_key",
"current_closure",
"legacy_handoff",
"id",
"implementation_reports",
"content_digest",
@@ -751,11 +1123,18 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"resource_key",
"risk_flags",
"state",
"tags",
"targets",
"title",
"updated_at",
], label);
return {
...caseProjection(item, label),
closure_history: array(
item.closure_history,
`${label}.closure_history`,
parseCaseHistory,
),
action_eligibility: parseActionEligibility(
item.action_eligibility,
`${label}.action_eligibility`,
@@ -766,6 +1145,11 @@ export function parseTicketDetail(value: unknown): TicketDetail {
item.assignment_diagnostics,
`${label}.assignment_diagnostics`,
),
assignment_tracking: array(
item.assignment_tracking,
`${label}.assignment_tracking`,
parseAssignmentTrackingProjection,
),
assignments: array(
item.assignments,
`${label}.assignments`,
@@ -820,7 +1204,8 @@ export function parseTicketDetail(value: unknown): TicketDetail {
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
resource_key: string(item.resource_key, `${label}.resource_key`),
risk_flags: strings(item.risk_flags, `${label}.risk_flags`),
state: string(item.state, `${label}.state`),
state: parseTicketState(item.state, `${label}.state`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
@@ -1370,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,121 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, received ${
JSON.stringify(actual)
}`,
);
}
}
async function assertRejects(operation: () => Promise<unknown>): Promise<void> {
try {
await operation();
} catch (error) {
if (error instanceof Error && error.message.includes("409")) return;
throw error;
}
throw new Error("Expected stale CAS rejection");
}
import type { TicketCaseAction } from "#lib/generated/ticket-api.ts";
import { ticketCaseRequest, updateTicketCase } from "./ticket-case.ts";
const observed = {
content_digest: "a".repeat(64),
state: "open",
closure_operation_key: "last-correction",
} as const;
const summary = {
id: "ticket-1",
resource_key: "T-1",
slug: "case",
title: "Case",
kind: "task",
labels: [],
tags: [{
workspace_id: "workspace one",
id: "tag-closed",
name: "closed",
last_operation_key: "create-tag",
deleted: false,
}],
priority: "P2",
status: "Closed",
workflow_state: "closed",
workflow_state_explicit: true,
};
Deno.test("case actions carry content/state/last-closure CAS without browser author or unrelated lifecycle fields", async () => {
const actions: TicketCaseAction[] = [
...["completed", "not_planned", "duplicate", "unknown", {
other: "Deferred",
}].map((reason) => ({ action: "close", reason } as TicketCaseAction)),
{ action: "reopen" },
{ action: "correct_closure", reason: "unknown" },
];
const original = globalThis.fetch;
try {
for (const action of actions) {
const request = ticketCaseRequest(
observed,
action,
" Case judgment ",
"operation-1",
);
assertEquals(request, {
expected_content_digest: observed.content_digest,
expected_state: "open",
expected_closure_operation_key: "last-correction",
operation_key: "operation-1",
action,
explanation: "Case judgment",
references: [],
});
globalThis.fetch = (input, init) => {
assertEquals(input, "/workspaces/workspace%20one/tickets/T%2F1/case");
const options = init as { method?: string; body?: string };
assertEquals(options.method, "POST");
assertEquals(JSON.parse(String(options.body)), request);
return Promise.resolve(Response.json(summary));
};
const result = await updateTicketCase("workspace one", "T/1", request);
assertEquals(result.workflow_state, "closed");
assertEquals(result.tags, summary.tags);
}
} finally {
globalThis.fetch = original;
}
assertEquals(
ticketCaseRequest(
{ ...observed, closure_operation_key: null },
{ action: "close", reason: "unknown" },
"Close",
"first",
).expected_closure_operation_key,
null,
);
});
Deno.test("case API propagates stale CAS failure rather than reporting success", async () => {
const original = globalThis.fetch;
globalThis.fetch = () =>
Promise.resolve(Response.json({ message: "stale case" }, { status: 409 }));
try {
await assertRejects(() =>
updateTicketCase(
"w",
"T-1",
ticketCaseRequest(
observed,
{ action: "reopen" },
"Reopen",
"operation-1",
),
)
);
} finally {
globalThis.fetch = original;
}
});
@@ -0,0 +1,47 @@
import type {
TicketCaseAction,
TicketCaseUpdateRequest,
TicketDetail,
TicketSummary,
} from "#lib/generated/ticket-api.ts";
import { workspaceApiJsonWithBody } from "./http.ts";
import {
parseTicketSummary,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
} from "./ticket-browser.ts";
export function ticketCaseRequest(
ticket: Pick<
TicketDetail,
"content_digest" | "state" | "closure_operation_key"
>,
action: TicketCaseAction,
explanation: string,
operationKey: string,
): TicketCaseUpdateRequest {
return {
expected_content_digest: ticket.content_digest,
expected_state: ticket.state,
// The last judgment key survives reopening; do not derive it from current_closure.
expected_closure_operation_key: ticket.closure_operation_key ?? null,
operation_key: operationKey,
action,
explanation: explanation.trim(),
references: [],
};
}
export function updateTicketCase(
workspaceId: string,
ticketId: string,
request: TicketCaseUpdateRequest,
): Promise<TicketSummary> {
return workspaceApiJsonWithBody(
`/workspaces/${encodeURIComponent(workspaceId)}/tickets/${
encodeURIComponent(ticketId)
}/case`,
{ method: "POST", body: JSON.stringify(request) },
parseTicketSummary,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
}
@@ -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,24 +392,20 @@ 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: "Ready + Planning"') &&
ticketPanelModel.includes('label: "In progress + Queued"') &&
ticketPanelModel.includes('label: "Done + Closed"') &&
ticketPanelModel.includes('label: "Open"') &&
ticketPanelModel.includes('label: "Closed"') &&
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
"Ticket Kanban should combine related states into independent 30-item display windows",
"Ticket Kanban should separate cases into independent 30-item display windows",
);
assert(
generatedTicketApi.includes(
@@ -428,15 +424,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
);
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
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('mutate("close", "/close"') &&
ticketDetailPage.includes("runCaseDecision") &&
ticketDetailPage.includes("TicketTags") &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_worker") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
@@ -10,29 +10,36 @@ export const fixturePage = (returned: number, more = false) => ({
});
const updated = (hour: number) =>
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
export function fixtureTicket(key: string, state: string, hour = 10) {
export function fixtureTicket(
key: string,
state: "open" | "closed",
hour = 10,
) {
return {
id: `internal-${key}`,
resource_key: key,
title: key === "T-101"
? "Restore queued Worker submissions"
: `${state} ticket ${key}`,
closure_operation_key: null,
current_closure: null,
legacy_handoff: null,
state,
tags: [],
priority: "normal",
queued_at: null,
queued_by: null,
updated_at: updated(hour),
record_source: "fixture",
workspace_action_priority: state === "inprogress" || state === "queued"
? "active_work"
: "background",
workspace_action_priority: state === "open" ? "active_work" : "background",
};
}
export function fixtureDetail(key = "T-101") {
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
const summary = fixtureTicket(key, "open");
const { workspace_action_priority: _, ...ticket } = summary;
return {
...ticket,
closure_history: [],
content_digest: "a".repeat(64),
created_at: updated(1),
readiness: null,
@@ -42,6 +49,7 @@ export function fixtureDetail(key = "T-101") {
artifacts: [],
artifact_count: 0,
assignments: [],
assignment_tracking: [],
assignment_diagnostics: [],
events: [],
event_count: 0,
@@ -87,7 +95,7 @@ export function fixtureDetail(key = "T-101") {
blockers: key === "T-101"
? [{
blocking_resource_key: "T-99",
blocking_state: "ready",
blocking_state: "open",
blocking_ticket: "internal-T-99",
note: null,
reason_kind: "dependency",
@@ -108,11 +116,13 @@ export function dashboardFixture(
const state = search.get("states")!;
const items = empty
? []
: state === "inprogress,queued"
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
: state === "done"
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
: [fixtureTicket("T-105", "closed", 14)];
: state === "open"
? [fixtureTicket("T-101", "open"), fixtureTicket("T-102", "open")]
: [
fixtureTicket("T-105", "closed", 14),
fixtureTicket("T-103", "closed", 12),
fixtureTicket("T-104", "closed", 7),
];
if (long && items[0]) {
items[0].title = "長いTicket名 — ".repeat(8) +
"preserve-the-full-title-without-horizontal-overflow";
@@ -35,9 +35,7 @@ export type Dashboard = {
};
const states: Record<string, string> = {
inprogress: "In progress",
queued: "Queued",
done: "Done",
open: "Open",
closed: "Closed",
active: "Active",
completed: "Completed",
@@ -90,8 +88,8 @@ export function recentRows(rows: DashboardRow[]): DashboardRow[] {
.slice(0, HOME_LIMIT);
}
// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are
// tool-only capabilities and must not be approximated by guessed HTTP routes.
// Homeは件数を絞ったread-onlyの一覧を使う。Ticketの検索・絞込みはTickets側の
// 公開query APIが担い、Homeで取得した一部分をWorkspace全体の検索結果にしない。
export function loadDashboard(
fetchFn: typeof fetch,
workspaceId: string,
@@ -113,8 +111,7 @@ export function loadDashboard(
})}`,
parseTicketListResponse,
);
const activeResult = tickets("inprogress,queued");
const doneResult = tickets("done");
const activeResult = tickets("open");
const closedResult = tickets("closed");
const objectivesResult = get(
`/objectives?limit=${HOME_LIMIT}`,
@@ -179,7 +176,7 @@ export function loadDashboard(
}
const current = detail.data;
// A concurrent transition can remove a Ticket from this snapshot's scope.
if (!["inprogress", "queued"].includes(current.state)) {
if (current.state !== "open") {
return { changed: true };
}
const blockers = current.relations.blockers;
@@ -218,17 +215,14 @@ export function loadDashboard(
: result.data.page.has_more || result.data.page.source_truncated
? `Showing ${
Math.min(result.data.items.length, HOME_LIMIT)
} active tickets, with in-progress work first.`
} open tickets.`
: undefined,
};
}),
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
[done, closed, objectives],
recent: Promise.all([closedResult, objectivesResult]).then((
[closed, objectives],
): DashboardFeed => ({
rows: recentRows([
...(done.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
...(closed.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
@@ -243,21 +237,17 @@ export function loadDashboard(
})),
]),
errors: [
...(!done.data ? [failure("Done tickets", done.error)] : []),
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
...([done.data, closed.data, objectives.data].some((data) =>
...([closed.data, objectives.data].some((data) =>
data?.invalid_records.length
)
? ["Some records could not be read."]
: []),
],
notice:
[done.data, closed.data, objectives.data].some((data) =>
data?.items.length
)
? "Up to 5 latest updates to done / closed tickets and Objectives."
: undefined,
notice: [closed.data, objectives.data].some((data) => data?.items.length)
? "Up to 5 latest updates to closed tickets and Objectives."
: undefined,
})),
};
}
@@ -86,10 +86,10 @@ Deno.test("observed live source movement requires fresh review but an unchanged
);
});
Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
Deno.test("closed Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
const ticket = parseTicketDetail({
...fixtureDetail(),
state: "done",
state: "closed",
evidence: {
...fixtureDetail().evidence,
has_merge_request: true,
@@ -107,7 +107,7 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
source_ref_observation: { status: "not_required" },
}],
});
assertEquals(ticket.state, "done");
assertEquals(ticket.state, "closed");
assertEquals(ticket.evidence.complete_for_integration, false);
assertEquals(
currentRequirementApprovalStatus(ticket.evidence),
@@ -128,14 +128,14 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
currentRequirementApprovalStatus(recovered.evidence),
"approved",
);
assertEquals(recovered.state, "done");
assertEquals(recovered.state, "closed");
assertEquals(
summarySourceReviewStatus(ticket.merge_requests[0]!),
"Integration approval recorded for immutable merged source source-1.",
);
});
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => {
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
const source = await Deno.readTextFile(
new URL(
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
@@ -144,15 +144,15 @@ Deno.test("Ticket route separates current requirement evidence from immutable in
);
for (
const required of [
"Current requirement evidence",
"MR requirement evidence",
"ticket.evidence.complete_for_integration",
"Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}",
"ticket.evidence.missing",
"summarySourceReviewStatus(mergeRequest)",
"Immutable integration evidence",
"Integration evidence error:",
'{#if ticket.state === "done"}',
"Incomplete current requirement evidence does not cancel recorded completion.",
'{#if ticket.state === "closed"}',
"Incomplete MR evidence does not alter the recorded case judgment.",
]
) {
if (!source.includes(required)) {
@@ -131,7 +131,7 @@
.ticket-kanban {
display: grid;
align-items: stretch;
grid-template-columns: repeat(3, minmax(18rem, 1fr));
grid-template-columns: repeat(2, minmax(18rem, 1fr));
gap: var(--space-3);
overflow-x: auto;
padding-bottom: var(--space-3);
@@ -168,13 +168,10 @@
border-radius: 50%;
background: var(--text-muted);
}
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
.ticket-lane[data-state="open"] .ticket-state-dot {
background: var(--success);
}
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
background: var(--warning);
}
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
.ticket-lane[data-state="closed"] .ticket-state-dot {
background: var(--accent);
}
.ticket-lane-count {
@@ -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";
@@ -23,56 +29,46 @@ function assertEquals<T>(actual: T, expected: T): void {
}
}
Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => {
Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => {
const tickets = [
{
id: "planning",
state: "planning",
title: "Planning",
updated_at: "2026-07-30T12:00:00Z",
},
{
id: "ready-old",
state: "ready",
title: "Ready old",
id: "old",
state: "open",
title: "Open old",
tags: [{
workspace_id: "workspace-a",
id: "tag-closed",
name: "closed",
last_operation_key: "create-closed",
deleted: false,
}],
updated_at: "2026-07-29T12:00:00Z",
},
{
id: "ready-new",
state: "ready",
title: "Ready new",
id: "new",
state: "open",
title: "Open new",
tags: [],
updated_at: "2026-07-30T12:00:00Z",
},
{ id: "queued", state: "queued", title: "Queued", updated_at: null },
{
id: "inprogress",
state: "inprogress",
title: "In progress",
id: "closed",
state: "closed",
title: "Closed",
tags: [{
workspace_id: "workspace-a",
id: "tag-ready",
name: "ready",
last_operation_key: "create-ready",
deleted: false,
}],
updated_at: null,
},
{ id: "closed", state: "closed", title: "Closed", updated_at: null },
{ id: "done", state: "done", title: "Done", updated_at: null },
] as TicketSummary[];
const lanes = ticketLanes(tickets);
assertEquals(lanes.map((lane) => lane.id), [
"ready-planning",
"inprogress-queued",
"done-closed",
]);
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [
"ready-new",
"ready-old",
"planning",
]);
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [
"inprogress",
"queued",
]);
assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [
"done",
"closed",
]);
assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]);
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]);
});
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
@@ -122,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,57 +1,35 @@
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 = [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] as const;
export const TICKET_STATES = ["open", "closed"] as const;
export type TicketState = (typeof TICKET_STATES)[number];
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
const LANE_DEFINITIONS = [
{
id: "ready-planning",
label: "Ready + Planning",
states: ["ready", "planning"],
},
{
id: "inprogress-queued",
label: "In progress + Queued",
states: ["inprogress", "queued"],
},
{
id: "done-closed",
label: "Done + Closed",
states: ["done", "closed"],
},
{ id: "open", label: "Open", states: ["open"] },
{ id: "closed", label: "Closed", states: ["closed"] },
] as const;
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"
>;
const STATE_SORT_ORDER = new Map<string, number>([
["ready", 0],
["planning", 1],
["inprogress", 0],
["queued", 1],
["done", 0],
["closed", 1],
]);
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;
@@ -77,9 +55,6 @@ export function sortTickets(
tickets: TicketCardSummary[],
): TicketCardSummary[] {
return [...tickets].sort((left, right) => {
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
(STATE_SORT_ORDER.get(right.state) ?? 99);
if (stateDelta !== 0) return stateDelta;
const updatedDelta = updatedAt(right) - updatedAt(left);
if (updatedDelta !== 0) return updatedDelta;
return left.id.localeCompare(right.id);
@@ -116,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("_", " ");
}
@@ -31,8 +31,8 @@
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
<div class="home-columns">
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
<DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
<DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
<DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} />
<DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} />
</div>
</div>
@@ -86,7 +86,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
);
await Promise.all(Object.values(result!.dashboard));
expect(depends).toHaveBeenCalledWith("workspace:home");
expect(fetch).toHaveBeenCalledTimes(7);
expect(fetch).toHaveBeenCalledTimes(6);
for (const [path, init] of fetch.mock.calls) {
const url = new URL(String(path), "https://example.test");
expect(init?.method ?? "GET").toBe("GET");
@@ -96,7 +96,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
expect(url.searchParams.get("limit")).toBe("5");
}
}
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=open")))
.toBe(true);
expect(
fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
@@ -152,7 +152,7 @@ test("one recent source failure preserves the other records and does not assert
expect((await screen.findByRole("alert")).textContent).toContain(
"Objectives",
);
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
expect(screen.queryByText("No closed tickets or Objectives yet."))
.toBeNull();
});
@@ -169,7 +169,7 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
const fallback = fixtureFetch();
const fetchFn: typeof fetch = (path, init) =>
String(path).endsWith("/tickets/T-101")
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "closed" }))
: fallback(path, init);
const feed = await data("home-owner", fetchFn).dashboard.active;
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
@@ -179,9 +179,9 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
test("empty resources have scoped empty states and no fabricated totals", async () => {
mount(data("home-empty"));
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
expect(await screen.findByText("No in-progress or queued tickets."))
expect(await screen.findByText("No open tickets."))
.toBeTruthy();
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
expect(await screen.findByText("No closed tickets or Objectives yet."))
.toBeTruthy();
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
@@ -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 };
};
@@ -1,6 +1,6 @@
<script lang="ts">
// workflow stateのlaneを横方向に比較するboard。狭い画面でもlaneを縦に分断せず、
// tickets.cssの局所横scrollを使う。key、title、blocker/attentionを判断の入口にする。
// laneは案件状態のopen/closedだけを表し、狭い画面では縦に並べる。
// 担当の自己申告handlingと分類タグは別に示し、laneやWorker稼働状態へ読み替えない。
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "#lib/workspace/api/http.ts";
@@ -13,27 +13,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;
};
@@ -45,21 +46,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),
);
@@ -83,10 +82,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[],
@@ -99,22 +104,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) {
@@ -129,13 +129,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: "",
@@ -170,7 +163,7 @@
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "planning",
workflow_state: "open",
targets: normalizedCreateTargets(),
};
try {
@@ -229,9 +222,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"
@@ -242,10 +235,6 @@
</button>
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>loaded tickets</span>
</div>
</div>
</header>
@@ -297,13 +286,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; progress 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}>
@@ -312,14 +318,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)}
@@ -327,12 +333,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>
@@ -341,7 +354,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>
@@ -349,3 +364,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,62 +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 = {
"ready-planning": ["ready", "planning"],
"inprogress-queued": ["inprogress", "queued"],
"done-closed": ["done", "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 [
readyPlanning,
inprogressQueued,
doneClosed,
orchestrator,
repositories,
] = await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-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,
@@ -65,14 +55,11 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return {
workspaceId,
ticketLanes: {
"ready-planning": readyPlanning,
"inprogress-queued": inprogressQueued,
"done-closed": doneClosed,
},
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,20 +12,22 @@
} 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 } 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 {
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { ApiResult } from "#lib/workspace/api/http.ts";
import type {
TicketCaseAction,
TicketClosureReason,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
@@ -37,7 +43,6 @@
access: TicketTargetAccess;
};
const MUTABLE_TICKET_STATES = TICKET_STATES;
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
@@ -78,16 +83,15 @@
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let progressBody = $state("");
let closureReason = $state("unknown");
let otherClosureReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let resolution = $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 }[]>([]);
@@ -139,21 +143,20 @@
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
targetDrafts = editableTargets(updatedTicket.targets);
nextState = updatedTicket.state;
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
progressBody = "";
closureReason = "unknown";
otherClosureReason = "";
threadRole = "comment";
threadBody = "";
resolution = "";
busy = null;
errorMessage = null;
queueMessage = null;
readyOperationKey = null;
caseOperation = null;
caseSaved = false;
manualRuntimeId = "";
manualWorkerId = "";
manualBindings = [];
@@ -227,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(
@@ -277,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 } : {}),
}),
},
@@ -301,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;
@@ -362,32 +333,43 @@
await mutate("target", "", targetEditBody(), "PATCH");
}
async function markReady() {
if (ticket.state !== "planning" || busy) return;
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
operation_key: readyOperationKey,
reason: transitionReason.trim() || null,
})
) {
readyOperationKey = null;
function selectedClosureReason(): TicketClosureReason {
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
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;
busy = "case";
caseOperation = operation;
errorMessage = null;
caseSaved = false;
try {
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);
transitionReason = "";
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function transition(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("state", "/state", {
state: nextState,
operation_key: crypto.randomUUID(),
expected_content_digest: ticket.content_digest,
expected_state: ticket.state,
reason: transitionReason.trim(),
body: progressBody.trim() || null,
})
) { transitionReason = ""; progressBody = ""; }
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
@@ -401,19 +383,6 @@
) threadBody = "";
}
async function closeTicket(event: SubmitEvent) {
event.preventDefault();
if (!resolution.trim()) return;
if (
await mutate("close", "/close", {
resolution: resolution.trim(),
operation_key: crypto.randomUUID(),
expected_content_digest: ticket.content_digest,
expected_state: ticket.state,
})
) resolution = "";
}
function eventTitle(kind: string): string {
return relationLabel(kind);
}
@@ -431,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}>
@@ -445,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}>
@@ -470,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>
@@ -480,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}
@@ -549,26 +523,34 @@
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Progress decision</h2></header>
<p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p>
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
<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
<select bind:value={closureReason}>
<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</option>
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label>
<label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}>
{nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"}
{#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 || 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 || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#if ticket.state === "planning"}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</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>
@@ -595,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>
@@ -631,12 +603,12 @@
</button>
</form></details>
{/if}
{#if ticket.state === "inprogress" && workerAssignment}
{#if workerAssignment}
<details class="ticket-cancel-implementation">
<summary>Cancel implementation</summary>
<form class="ticket-control-form" onsubmit={cancelImplementation}>
<p class="workspace-empty-copy">
Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir.
Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir.
</p>
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
<button
@@ -644,7 +616,7 @@
type="submit"
disabled={busy !== null || !cancellationReason.trim()}
>
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"}
</button>
</form>
</details>
@@ -724,16 +696,16 @@
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p>
{#if ticket.state === "done"}
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</section>
{/if}
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
@@ -759,20 +731,33 @@
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p>
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
{/if}
</section>
{#if ticket.state !== "closed"}
<details class="ticket-control-card ticket-close-card">
<summary>Close ticket</summary>
<form class="ticket-control-form" onsubmit={closeTicket}>
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
<button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button>
</form>
{#if ticket.current_closure}
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
<RichMarkdown text={ticket.current_closure.explanation} />
{#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</section>
{/if}
{#if ticket.closure_history.length > 0}
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
{#each ticket.closure_history as judgment (judgment.operation_key)}
<article>
<p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p>
<RichMarkdown text={judgment.explanation} />
{#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
</article>
{/each}
</details>
{/if}
{#if ticket.legacy_handoff}
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
</details>
{:else if ticket.resolution}
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
{/if}
</aside>
</div>
@@ -25,11 +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 runCaseDecision(",
"async function mutateAssignment(",
]
) {
+5
View File
@@ -40,6 +40,11 @@ export default defineConfig({
},
proxy: {
// Case judgments use the canonical authenticated Workspace API route.
"/workspaces": {
target: "http://127.0.0.1:8787",
changeOrigin: true,
},
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true,