feat: separate Ticket case state and immutable closure judgments

This commit is contained in:
2026-10-11 01:41:46 +09:00
parent 6a12b6cc34
commit ba3ebaa68c
65 changed files with 7625 additions and 6844 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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
+24 -12
View File
@@ -1,26 +1,22 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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 BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
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 +25,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 +55,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; };
@@ -91,7 +89,17 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri
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 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>; 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; 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 +119,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; 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; };
@@ -133,12 +143,14 @@ 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; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; };
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";
@@ -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:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
// 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
@@ -48,7 +48,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () =>
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "ready",
state: "open",
priority: "P2",
updated_at: null,
queued_by: null,
@@ -105,16 +105,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() {
@@ -131,6 +138,10 @@ function ticketDetailFixture() {
artifacts: [],
assignment_diagnostics: [],
assignments: [],
closure_history: [],
closure_operation_key: null,
current_closure: null,
legacy_handoff: null,
body: "Body",
body_truncated: false,
current_worker: null,
@@ -164,7 +175,7 @@ function ticketDetailFixture() {
resolution: null,
resource_key: "T-1",
risk_flags: [],
state: "planning",
state: "open",
targets: [
{
repository_key: "docs",
@@ -342,11 +353,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 +365,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 +451,135 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
"unknown field unexpected",
);
});
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("case projections retain reasons, actors, references and migration history after reopening", () => {
const fixture = {
...ticketDetailFixture(),
state: "open",
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(
parseTicketDetail({ ...fixture, state: "closed", current_closure: closure })
.current_closure,
closure,
);
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",
priority: "P2",
record_source: "sqlite",
workspace_action_priority: "background",
closure_operation_key: "close-1",
current_closure: closure,
legacy_handoff: legacyHandoff,
}],
});
assertEquals(list.items[0].current_closure, closure);
assertEquals(list.items[0].legacy_handoff, legacyHandoff);
});
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,
@@ -20,6 +21,10 @@ import type {
TicketActionEligibility,
TicketAssignmentPrincipal,
TicketAssignmentSummary,
TicketCaseAction,
TicketCaseHistoryEntry,
TicketClosureJudgment,
TicketClosureReason,
TicketDetail,
TicketDetailDerivedRelation,
TicketDetailRelation,
@@ -29,14 +34,17 @@ import type {
TicketEventDetail,
TicketEvidenceEvent,
TicketEvidenceSummary,
TicketLegacyWorkflowSnapshot,
TicketListItemSummary,
TicketListResponse,
TicketMergeRequestSummary,
TicketQueueOutcome,
TicketRef,
TicketReference,
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentRecord,
TicketRoleAssignmentSummary,
TicketSummary,
TicketTarget,
} from "#lib/generated/ticket-api.ts";
@@ -183,12 +191,208 @@ 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 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",
"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,
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",
@@ -201,13 +405,14 @@ function parseTicketListItem(
"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`),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
workspace_action_priority: string(
@@ -727,6 +932,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"artifacts",
"assignment_diagnostics",
"assignments",
"closure_history",
"body",
"body_truncated",
"created_at",
@@ -735,6 +941,9 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"event_page",
"events",
"evidence",
"closure_operation_key",
"current_closure",
"legacy_handoff",
"id",
"implementation_reports",
"content_digest",
@@ -756,6 +965,12 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"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`,
@@ -820,7 +1035,7 @@ 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`),
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
@@ -0,0 +1,116 @@
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: [],
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));
};
assertEquals(
(await updateTicketCase("workspace one", "T/1", request))
.workflow_state,
"closed",
);
}
} 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,
);
}
@@ -404,12 +404,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
);
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,7 +427,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
);
assert(
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("const path = ticketPath") &&
ticketDetailPage.includes("`${path}/queue`") &&
@@ -436,7 +435,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
ticketDetailPage.includes("mergeRequestPagePath") &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("await updateTicketCase(") &&
ticketDetailPage.includes("mutateAssignment") &&
ticketDetailPage.includes("can_start_manual_worker") &&
ticketDetailPage.includes("ticket.relations.outgoing"),
@@ -10,29 +10,35 @@ 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,
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,
@@ -87,7 +93,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 +114,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",
@@ -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 {
@@ -23,56 +23,26 @@ 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",
updated_at: "2026-07-29T12:00:00Z",
},
{
id: "ready-new",
state: "ready",
title: "Ready new",
id: "new",
state: "open",
title: "Open new",
updated_at: "2026-07-30T12:00:00Z",
},
{ id: "queued", state: "queued", title: "Queued", updated_at: null },
{
id: "inprogress",
state: "inprogress",
title: "In progress",
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", () => {
@@ -4,35 +4,12 @@ import type {
TicketListItemSummary,
} 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;
@@ -44,15 +21,6 @@ export type TicketCardSummary = Pick<
"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 TicketLane = {
id: TicketLaneId;
label: string;
@@ -77,9 +45,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);
@@ -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);
});
@@ -168,7 +168,7 @@
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "planning",
workflow_state: "open",
targets: normalizedCreateTargets(),
};
try {
@@ -297,7 +297,7 @@
</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.
Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly.
</p>
{/if}
@@ -9,11 +9,7 @@ import type { TicketListResponse } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageLoad } from "./$types";
const LANE_STATES = {
"ready-planning": ["ready", "planning"],
"inprogress-queued": ["inprogress", "queued"],
"done-closed": ["done", "closed"],
} as const;
const LANE_STATES = { open: ["open"], closed: ["closed"] } as const;
export type TicketLaneId = keyof typeof LANE_STATES;
@@ -48,15 +44,13 @@ async function loadLane(
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [
readyPlanning,
inprogressQueued,
doneClosed,
open,
closed,
orchestrator,
repositories,
] = await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-closed"),
loadLane(fetch, workspaceId, "open"),
loadLane(fetch, workspaceId, "closed"),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
@@ -69,9 +63,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
return {
workspaceId,
ticketLanes: {
"ready-planning": readyPlanning,
"inprogress-queued": inprogressQueued,
"done-closed": doneClosed,
open,
closed,
},
orchestrator,
repositories,
@@ -12,16 +12,18 @@
parseTicketQueueOutcome,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest, updateTicketCase } 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 +39,6 @@
access: TicketTargetAccess;
};
const MUTABLE_TICKET_STATES = TICKET_STATES;
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
@@ -78,12 +79,11 @@
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);
@@ -139,17 +139,16 @@
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;
@@ -363,7 +362,7 @@
}
async function markReady() {
if (ticket.state !== "planning" || busy) return;
if (ticket.state !== "open" || busy) return;
readyOperationKey ??= crypto.randomUUID();
if (
await mutate("ready", "/ready", {
@@ -376,18 +375,33 @@
}
}
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 selectedClosureReason(): TicketClosureReason {
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || !transitionReason.trim()) return;
const generation = routeGeneration;
const path = ticketPath;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
busy = action.action;
errorMessage = null;
try {
await updateTicketCase(data.workspaceId, data.ticketId, request);
if (generation !== routeGeneration) return;
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;
}
}
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
@@ -401,19 +415,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);
}
@@ -550,22 +551,29 @@
<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}
<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>
<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 || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/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 ticket.state === "open"}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button>
{/if}
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
@@ -631,12 +639,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 +652,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 +732,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 +767,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>
@@ -30,6 +30,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
const operation of [
"async function mutate(",
"async function queueTicket(",
"async function caseDecision(",
"async function mutateAssignment(",
]
) {
+5
View File
@@ -40,6 +40,11 @@ export default defineConfig({
},
proxy: {
// Case judgments use the canonical authenticated Workspace API route.
"/workspaces": {
target: "http://127.0.0.1:8787",
changeOrigin: true,
},
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true,