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:
@@ -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"; };
|
||||
|
||||
|
||||
@@ -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(",
|
||||
]
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user