diff --git a/crates/workspace-server/src/assignment_delivery_tests.rs b/crates/workspace-server/src/assignment_delivery_tests.rs index bff949207..8311f50f7 100644 --- a/crates/workspace-server/src/assignment_delivery_tests.rs +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -619,11 +619,34 @@ async fn assignment_delivery_rendered_content_is_immutable_across_restart_and_co #[test] fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history() { + use crate::authority::{SqliteWorkspaceAuthority, TicketAuthority}; + use server_api::{TicketQueryRequest, TicketShowRequest}; + use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug}; + let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("schema92.db"); // A genuine retained Ticket-10/Workspace-91 fixture goes through its actual // applied tracking migration; no current DB markers or columns are lowered. let conn = tracking_schema91_fixture(&path); + // Public browse needs the resource-key rows a real old Workspace already + // stored. Seed that old input before migration, never patch its projection. + let ids: Vec = conn + .prepare("SELECT ticket_id FROM typed_tickets ORDER BY ticket_id") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + for id in ids { + allocate_resource_key( + &conn, + "w", + WorkspaceResourceKind::Ticket, + &id, + "2026-10-10T00:00:00Z", + ) + .unwrap(); + } migrate_ticket_tracking_v91_to_v92(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 92); let assignments = stored_fixture_rows(&conn, "ticket_worker_assignments"); @@ -668,6 +691,176 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l Ok(()) }) .unwrap(); + drop(store); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let principal = ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }; + for (id, status, legacy_state) in [ + ( + "pending", + Some(TicketAssignmentTrackingStatus::Waiting), + "queued", + ), + ( + "progress", + Some(TicketAssignmentTrackingStatus::Inprogress), + "inprogress", + ), + ("closed-reopen", None, "queued"), + ("historical-done", None, "done"), + ] { + let stored = reopened + .get_ticket_assignment_tracking("w", id, &format!("a-{id}")) + .unwrap() + .unwrap(); + assert_eq!(stored.tracking.as_ref().map(|t| t.status), status, "{id}"); + assert_eq!(stored.principal, principal, "{id}"); + assert!(stored.current, "{id}"); + assert!(stored.last_event_id.is_some(), "{id}"); + assert!(stored.migration_diagnostic.is_some(), "{id}"); + let detail = authority.ticket(id).unwrap(); + assert_eq!(detail.assignment_tracking, vec![stored], "{id}"); + assert_eq!( + authority.ticket_assignment_tracking(id).unwrap(), + detail.assignment_tracking, + "{id}" + ); + let shown = authority + .show_ticket(id, TicketShowRequest::default()) + .unwrap(); + assert_eq!( + shown.assignment_tracking, detail.assignment_tracking, + "{id}" + ); + let saved_ticket = backend.show(TicketIdOrSlug::Id(id.into())).unwrap(); + assert_eq!( + detail.state.as_str(), + saved_ticket.meta.workflow_state.as_str(), + "{id}" + ); + assert_eq!(detail.closure_history, saved_ticket.case_history, "{id}"); + assert_eq!( + detail.legacy_handoff, saved_ticket.legacy_workflow_snapshot, + "{id}" + ); + assert_eq!( + detail.legacy_handoff.as_ref().unwrap().workflow_state, + legacy_state, + "{id}" + ); + assert_eq!(shown.closure_history, detail.closure_history, "{id}"); + assert_eq!(shown.legacy_handoff, detail.legacy_handoff, "{id}"); + assert_eq!( + detail.state.as_str(), + if id == "historical-done" { + "closed" + } else { + "open" + }, + "{id}" + ); + if id == "closed-reopen" { + assert_eq!(detail.closure_history.len(), 2); + assert_eq!( + detail.closure_history[0].action, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown + } + ); + assert_eq!( + detail.closure_history[1].action, + ticket::TicketCaseAction::Reopen + ); + } + } + let assigned = authority + .query_tickets(TicketQueryRequest { + assigned_principal: Some(principal.clone()), + limit: Some(100), + ..Default::default() + }) + .unwrap(); + let mut assigned_ids = assigned + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + assigned_ids.sort_unstable(); + assert_eq!( + assigned_ids, + vec![ + "closed-reopen", + "historical-done", + "invalid-fence", + "no-snapshot", + "pending", + "progress", + "replaced" + ] + ); + assert!(!assigned.page.has_more); + for item in &assigned.items { + assert_eq!( + item.assignment_tracking, + reopened + .list_current_ticket_assignment_tracking("w", &item.id) + .unwrap(), + "{}", + item.id + ); + let detail = authority.ticket(&item.id).unwrap(); + assert_eq!(item.legacy_handoff, detail.legacy_handoff, "{}", item.id); + assert_eq!(item.state, detail.state, "{}", item.id); + } + for (status, expected) in [ + (TicketAssignmentTrackingStatus::Waiting, vec!["pending"]), + (TicketAssignmentTrackingStatus::Inprogress, vec!["progress"]), + (TicketAssignmentTrackingStatus::Done, vec![]), + ] { + let filtered = authority + .query_tickets(TicketQueryRequest { + assigned_principal: Some(principal.clone()), + handling_status: Some(status), + limit: Some(1), + ..Default::default() + }) + .unwrap(); + assert_eq!( + filtered + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + expected, + "{status:?}" + ); + assert!(!filtered.page.has_more, "{status:?}"); + assert!(filtered.page.next_cursor.is_none(), "{status:?}"); + } + assert!( + authority + .query_tickets(TicketQueryRequest { + assigned_principal: Some( + ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { + agent_key: "other-agent".into(), + } + ), + limit: Some(1), + ..Default::default() + }) + .unwrap() + .items + .is_empty() + ); + assert_eq!( + reopened + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(), + pending + ); } #[tokio::test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cb544a19a..119038c80 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -36926,6 +36926,9 @@ mod db_execution_tests; mod tests { mod assignment_delivery_tests { include!("server_assignment_delivery_tests.rs"); + mod ticket_web_roundtrip_tests { + include!("server_ticket_web_roundtrip_tests.rs"); + } } mod assignment_start_tests { @@ -36933,6 +36936,10 @@ mod tests { } mod assignment_tracking_tests { include!("server_assignment_tracking_tests.rs"); + mod tools_backend_tests { + use super::*; + include!("server_ticket_tools_backend_tests.rs"); + } } mod ticket_case_signed_tests { diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 8c59386e6..5d397814a 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -319,6 +319,39 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del delivery_stop(&api, &worker); } +async fn delivery_public_assignment_snapshot( + api: &WorkspaceApi, + ticket: &str, + assignment: &str, +) -> (Value, Value, Value) { + let root = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}"); + let detail = delivery_http( + api, + "GET", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"), + None, + StatusCode::OK, + ) + .await; + let retained = delivery_http( + api, + "GET", + &format!("{root}/assignment-tracking?assignment_id={assignment}"), + None, + StatusCode::OK, + ) + .await; + let history = delivery_http( + api, + "GET", + &format!("{root}/assignment-tracking/history?assignment_id={assignment}&limit=100"), + None, + StatusCode::OK, + ) + .await; + (detail, retained, history) +} + #[tokio::test] async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() { let dir = tempfile::tempdir().unwrap(); @@ -331,6 +364,25 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne let initial = delivery_tracking(&api, &ticket.id, assignment); delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await; let done = delivery_tracking(&api, &ticket.id, assignment); + let (done_detail, done_public, done_history) = + delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await; + let expected_done = serde_json::to_value(&done).unwrap(); + assert_eq!( + expected_done["tracking"]["result"], + "Returned options without closing" + ); + assert_eq!(done_detail["state"], "open"); + assert_eq!(done_detail["assignment_tracking"], json!([expected_done])); + assert_eq!(done_public, done_detail["assignment_tracking"]); + let history_items = done_history["items"].as_array().unwrap(); + assert_eq!(history_items.len(), 2, "initial waiting and reported done"); + assert_eq!(history_items[0]["tracking"]["status"], "waiting"); + assert_eq!( + history_items[1]["event_id"], + done.last_event_id.as_deref().unwrap() + ); + assert_eq!(history_items[1]["tracking"], expected_done["tracking"]); + assert!(done_history["next_event_id"].is_null()); assert_eq!( api.authority.ticket(&ticket.id).unwrap().state.as_str(), "open" @@ -373,6 +425,29 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne "case history must not revive done delegation" ); assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done); + let (detail, public, history) = + delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await; + assert_eq!( + detail["state"], + if key == "close-return" { + "closed" + } else { + "open" + } + ); + assert_eq!( + detail["closure_history"].as_array().unwrap().len(), + if key == "close-return" { 1 } else { 2 } + ); + assert_eq!( + detail["assignment_tracking"], + done_detail["assignment_tracking"] + ); + assert_eq!(public, done_public, "case decision rewrote public handling"); + assert_eq!( + history, done_history, + "case decision rewrote handling history" + ); } let reassigned = delivery_assign(&api, &ticket.id, "reissue-return").await; let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap(); @@ -395,6 +470,69 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne .status, ticket::TicketAssignmentTrackingStatus::Waiting ); + let (detail, old_public, old_history) = + delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await; + let (_, new_public, new_history) = + delivery_public_assignment_snapshot(&api, &ticket.id, new_assignment).await; + assert_eq!(detail["state"], "open"); + assert_eq!(detail["closure_history"].as_array().unwrap().len(), 2); + assert_eq!( + old_public, + json!([serde_json::to_value(&retained).unwrap()]) + ); + assert_eq!(old_public[0]["tracking"], expected_done["tracking"]); + assert_eq!( + old_public[0]["last_event_id"], + expected_done["last_event_id"] + ); + assert_eq!(old_public[0]["release"]["action"], "replaced"); + assert_eq!( + old_public[0]["release"]["replacement_assignment_id"], + new_assignment + ); + assert_eq!(old_public[0]["release"]["operation_key"], "reissue-return"); + assert_eq!(old_history, done_history, "reissue changed old history"); + assert_eq!(detail["assignment_tracking"], new_public); + assert_eq!(new_public[0]["assignment_id"], new_assignment); + assert_eq!(new_public[0]["current"], true); + assert_eq!(new_public[0]["tracking"]["status"], "waiting"); + assert!(new_public[0]["tracking"]["result"].is_null()); + assert!(new_public[0]["release"].is_null()); + assert_eq!(new_history["items"].as_array().unwrap().len(), 1); + assert_eq!( + new_history["items"][0]["event_id"], + new_public[0]["last_event_id"] + ); + // Follow the public cursor rather than assuming current-first ordering. + let page_root = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/assignment-tracking/assignments", + ticket.id + ); + let first = delivery_http( + &api, + "GET", + &format!("{page_root}?limit=1"), + None, + StatusCode::OK, + ) + .await; + let cursor = first["next_assignment_id"] + .as_str() + .expect("two assignments need a cursor"); + let second = delivery_http( + &api, + "GET", + &format!("{page_root}?limit=1&after_assignment_id={cursor}"), + None, + StatusCode::OK, + ) + .await; + assert!(second["next_assignment_id"].is_null()); + assert_eq!(first["items"].as_array().unwrap().len(), 1); + assert_eq!(second["items"].as_array().unwrap().len(), 1); + let rows = [&first["items"][0], &second["items"][0]]; + assert!(rows.contains(&&old_public[0])); + assert!(rows.contains(&&new_public[0])); // An exact old Assign retry returns its receipt, never revives old work. assert_eq!( delivery_assign(&api, &ticket.id, "assign-return").await, diff --git a/crates/workspace-server/src/server_ticket_tools_backend_tests.rs b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs new file mode 100644 index 000000000..637d44a5d --- /dev/null +++ b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs @@ -0,0 +1,498 @@ +// Tools/WIP -> per-request Runtime signature -> production router/SQLite. +// Reuses the workdirless tracking fixture; its Runtime records execution rather +// than creating sessions. No LLM, repository, live service or delivery E2E here. +// Run: cargo test -p yoi-workspace-server --lib tools_backend_tests +use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolOutput}; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use worker::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; +use worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + WorkspaceResponse, +}; + +struct TicketRouterClient { + api: WorkspaceApi, + identity: Arc, + source: RuntimeWorkerRef, + runtime: tokio::runtime::Handle, + lose_next_response: AtomicBool, + requests: Mutex>, +} +impl std::fmt::Debug for TicketRouterClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignedTicketRouterClient") + } +} +impl WorkspaceClient for TicketRouterClient { + fn workspace_id(&self) -> Option<&str> { + Some(TEST_WORKSPACE_ID) + } + fn kind(&self) -> &str { + "signed-ticket-router" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> std::result::Result { + let method = match request.method { + WorkspaceRequestMethod::Get => "GET", + WorkspaceRequestMethod::Post => "POST", + WorkspaceRequestMethod::Put => "PUT", + WorkspaceRequestMethod::Patch => "PATCH", + WorkspaceRequestMethod::Delete => "DELETE", + }; + let signed = runtime_source_request( + &self.identity, + Some(&self.source.worker_id), + method, + &request.path, + request.body.unwrap_or_default().into_bytes(), + ); + // Test tool dispatch runs on spawn_blocking, so this sync transport + // can drive the router without nesting runtimes on an async thread. + self.runtime.block_on(async { + let response = build_router(self.api.clone()) + .oneshot(signed) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + let body = String::from_utf8(bytes.to_vec()).unwrap(); + let mutation = request.path.ends_with("/assignment-answer") + || request.path.ends_with("/implementation-results"); + self.requests + .lock() + .unwrap() + .push((request.path, status, body.clone())); + if method == "POST" + && mutation + && (200..300).contains(&status) + && self.lose_next_response.swap(false, Ordering::SeqCst) + { + return Err(WorkspaceClientError::Request( + "response lost after commit; outcome unknown".into(), + )); + } + Ok(WorkspaceResponse { status, body }) + }) + } +} + +#[derive(Clone)] +struct NoTicketLlm; +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for NoTicketLlm { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + _: agen::llm_client::Request, + ) -> std::result::Result + { + panic!("Ticket tools fixture must not call an LLM") + } +} +async fn execute_tool( + tool: Arc, + input: serde_json::Value, + context: ToolExecutionContext, +) -> std::result::Result { + tokio::task::spawn_blocking(move || { + futures::executor::block_on(tool.execute(&input.to_string(), context)).map(output_json) + }) + .await + .unwrap() +} +fn output_json(output: ToolOutput) -> serde_json::Value { + serde_json::from_str(output.content.as_deref().unwrap_or(&output.summary)).unwrap() +} +struct TicketSurface { + tools: BTreeMap>, + wip: bool, +} +impl TicketSurface { + async fn new(root: &Path, client: Arc, wip: bool) -> Self { + let manifest = worker::WorkerManifest::from_toml("[worker]\nname=\"ticket-tools\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap(); + let engine = + agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( + NoTicketLlm, + ); + let mut host = worker::Worker::new( + manifest, + engine, + session_store::FsStore::new(root.join("sessions")).unwrap(), + worker::WorkerWorkspaceContext::with_client( + Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()), + client.clone(), + ), + worker::WorkerFilesystemAuthority::None, + manifest::Scope::empty(), + ) + .await + .unwrap(); + let report = + host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module( + TicketFeature::new(client.clone(), TicketFeatureAccess::workflow()), + )); + assert!(!report.has_errors()); + let handle = host.engine().tool_server_handle(); + handle.flush_pending(); + let tools = [ + "TicketAssignmentTrackingQuery", + "TicketAssignmentAnswer", + "TicketImplementationResult", + "TicketImplementationResultQuery", + ] + .into_iter() + .map(|name| (name.into(), handle.get_tool(name).unwrap().1)) + .collect(); + if !wip { + return Self { tools, wip }; + } + let mut mounts = worker::wip::WipMountRegistry::new(); + let route = mounts.allocate_namespace("ticket", "tickets").unwrap(); + worker::feature::builtin::ticket::mount_workspace_http_ticket_wip( + &mut mounts, + client, + TicketFeatureAccess::workflow(), + None, + &route, + ) + .unwrap(); + let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoTicketLlm); + worker::wip::install_wip_mode_with_mounts(&mut engine, None, "ticket-tools".into(), mounts) + .unwrap(); + let handle = engine.tool_server_handle(); + handle.flush_pending(); + Self { + tools: ["Inspect", "Invoke"] + .into_iter() + .map(|name| (name.into(), handle.get_tool(name).unwrap().1)) + .collect(), + wip, + } + } + async fn call( + &self, + name: &str, + mut input: serde_json::Value, + context: ToolExecutionContext, + ) -> std::result::Result { + if !self.wip { + return execute_tool(self.tools[name].clone(), input, context).await; + } + let path = format!("/tickets/{}", input["ticket"].as_str().unwrap()); + input.as_object_mut().unwrap().remove("ticket"); + // Observe the real item representation before invoking a mutation. Tracking + // reads alone do not establish the Ticket WIP object validator. Explicit + // refresh also reconciles stale observations after an unknown result; + // it does not resend the operation or manufacture an observed CAS value. + let inspected = execute_tool( + self.tools["Inspect"].clone(), + serde_json::json!({"path":path,"refresh":true}), + ToolExecutionContext::direct(), + ) + .await?; + execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":"read","arguments":{}}), ToolExecutionContext::direct()).await?; + let inspected = execute_tool( + self.tools["Inspect"].clone(), + serde_json::json!({"path":path,"refresh":true}), + ToolExecutionContext::direct(), + ) + .await?; + let operation = match name { + "TicketAssignmentTrackingQuery" => "assignment_tracking_query", + "TicketAssignmentAnswer" => "assignment_answer", + "TicketImplementationResult" => "implementation_result", + "TicketImplementationResultQuery" => "implementation_result_query", + _ => unreachable!(), + }; + execute_tool(self.tools["Invoke"].clone(), serde_json::json!({"path":path,"interface":inspected["interfaces"][0]["reference"],"operation":operation,"arguments":input}), context).await + } +} +fn ticket_client( + api: &WorkspaceApi, + identity: RuntimeIdentityMaterial, + source: &RuntimeWorkerRef, +) -> Arc { + Arc::new(TicketRouterClient { + api: api.clone(), + identity: Arc::new(identity), + source: source.clone(), + runtime: tokio::runtime::Handle::current(), + lose_next_response: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + }) +} +fn context(call: &str) -> ToolExecutionContext { + ToolExecutionContext::new(call, "ticket-tools-batch", 0) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waiting_delegation() { + for wip in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let input_count = runtime.input_requests.lock().unwrap().len(); + let client = ticket_client(&api, identity, &source); + let surface = TicketSurface::new(dir.path(), client.clone(), wip).await; + let observed = surface + .call( + "TicketAssignmentTrackingQuery", + serde_json::json!({"ticket":ticket}), + context("read"), + ) + .await + .unwrap(); + assert_eq!(observed[0]["assignment_id"], current.assignment_id); + assert_eq!( + observed[0]["last_event_id"], + serde_json::to_value(¤t.last_event_id).unwrap() + ); + assert_eq!(observed[0]["tracking"]["status"], "waiting"); + let input = serde_json::json!({"ticket":ticket,"assignment_id":observed[0]["assignment_id"],"expected_last_event_id":observed[0]["last_event_id"],"operation_key":"answer-observed","body":"Use the bounded option"}); + client.lose_next_response.store(true, Ordering::SeqCst); + let error = surface + .call("TicketAssignmentAnswer", input.clone(), context("answer")) + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome unknown"), "{error}"); + let writes = || { + client + .requests + .lock() + .unwrap() + .iter() + .filter(|(p, _, _)| p.ends_with("/assignment-answer")) + .cloned() + .collect::>() + }; + assert_eq!(writes().len(), 1, "unknown outcome must not silently retry"); + let committed: serde_json::Value = serde_json::from_str(&writes()[0].2).unwrap(); + let receipt = surface + .call("TicketAssignmentAnswer", input.clone(), context("answer")) + .await + .unwrap(); + assert_eq!(receipt, committed); + assert_eq!( + surface + .call("TicketAssignmentAnswer", input.clone(), context("answer")) + .await + .unwrap(), + receipt + ); + assert_eq!(receipt["assignment_id"], observed[0]["assignment_id"]); + let mut stale = input.clone(); + stale["operation_key"] = "stale-answer".into(); + stale["expected_last_event_id"] = "unobserved-event".into(); + let error = surface + .call("TicketAssignmentAnswer", stale, context("stale")) + .await + .unwrap_err(); + assert!(error.to_string().contains("409"), "{error}"); + let mut refused = input.clone(); + refused["body"] = "Different answer under same operation".into(); + let error = surface + .call("TicketAssignmentAnswer", refused, context("answer")) + .await + .unwrap_err(); + assert!(error.to_string().contains("409"), "{error}"); + // A valid Runtime signature is not a Worker membership grant. The + // Backend must reject this unregistered source through the same tools. + let denied = Arc::new(TicketRouterClient { + api: api.clone(), + identity: client.identity.clone(), + source: RuntimeWorkerRef::new( + source.runtime_id.clone(), + "00000000-0000-7000-8000-000000000099", + ), + runtime: tokio::runtime::Handle::current(), + lose_next_response: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + }); + let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await; + let error = denied_surface + .call("TicketAssignmentAnswer", input.clone(), context("denied")) + .await + .unwrap_err(); + assert!(error.to_string().contains("401"), "{error}"); + assert_eq!( + runtime.input_requests.lock().unwrap().len(), + input_count, + "self Answer must not enqueue a new input to its own Orchestrator" + ); + let saved = browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(); + let events: Vec<_> = saved + .events + .iter() + .filter(|e| e.kind.as_str() == "assignment_answer") + .collect(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].body.as_str(), input["body"].as_str().unwrap()); + assert_eq!( + events[0].author.as_deref(), + Some(format!("worker:{}/{}", source.runtime_id, source.worker_id).as_str()) + ); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); + assert_eq!( + surface + .call( + "TicketAssignmentTrackingQuery", + serde_json::json!({"ticket":ticket}), + context("read-after") + ) + .await + .unwrap(), + observed + ); + } +} + +fn report_counts(api: &WorkspaceApi) -> (i64, i64) { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + ( + conn.query_row( + "SELECT count(*) FROM ticket_implementation_results", + [], + |r| r.get(0), + ) + .unwrap(), + conn.query_row( + "SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", + [], + |r| r.get(0), + ) + .unwrap(), + ) +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn implementation_result_tool_and_wip_keep_host_owned_retry_identity_without_assignments() { + for wip in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let (api, _runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + // Deliberately do not call assign_tracking: reporting needs no delegation/MR. + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(); + let client = ticket_client(&api, identity, &source); + let surface = TicketSurface::new(dir.path(), client.clone(), wip).await; + let input = + serde_json::json!({"ticket":ticket,"kind":"result","body":"Repository-free findings"}); + client.lose_next_response.store(true, Ordering::SeqCst); + let error = surface + .call( + "TicketImplementationResult", + input.clone(), + context("report-a"), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome unknown"), "{error}"); + assert_eq!(report_counts(&api), (1, 1)); + let writes = || { + client + .requests + .lock() + .unwrap() + .iter() + .filter(|(p, _, _)| p.ends_with("/implementation-results")) + .cloned() + .collect::>() + }; + assert_eq!(writes().len(), 1); + let committed: serde_json::Value = serde_json::from_str(&writes()[0].2).unwrap(); + let receipt = surface + .call( + "TicketImplementationResult", + input.clone(), + context("report-a"), + ) + .await + .unwrap(); + assert_eq!(receipt, committed); + assert_eq!( + surface + .call( + "TicketImplementationResult", + input.clone(), + context("report-a") + ) + .await + .unwrap(), + receipt + ); + assert_eq!(receipt["request"]["body"], input["body"]); + assert_eq!(report_counts(&api), (1, 1)); + let queried = surface + .call( + "TicketImplementationResultQuery", + serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]}), + context("query"), + ) + .await + .unwrap(); + assert_eq!(queried, receipt); + let mut changed = input.clone(); + changed["body"] = "Different body".into(); + let error = surface + .call("TicketImplementationResult", changed, context("report-a")) + .await + .unwrap_err(); + assert!(error.to_string().contains("409"), "{error}"); + assert_eq!(report_counts(&api), (1, 1)); + let other = surface + .call("TicketImplementationResult", input, context("report-b")) + .await + .unwrap(); + assert_ne!(other["result_id"], receipt["result_id"]); + assert_eq!(report_counts(&api), (2, 2)); + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(), + before + ); + assert!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Worker + ) + .unwrap() + .is_none() + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let suppressed: i64 = conn.query_row("SELECT count(*) FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.kind='implementation_result' AND d.disposition='suppress' AND d.reason='no_recipient'", [], |r| r.get(0)).unwrap(); + assert_eq!(suppressed, 2); + } +} diff --git a/crates/workspace-server/src/server_ticket_web_roundtrip_tests.rs b/crates/workspace-server/src/server_ticket_web_roundtrip_tests.rs new file mode 100644 index 000000000..454fea513 --- /dev/null +++ b/crates/workspace-server/src/server_ticket_web_roundtrip_tests.rs @@ -0,0 +1,207 @@ +// Real router response bytes -> production Web parser/operation/summary. The +// sibling lifecycle suite owns delivery; this bridge replaces only the Runtime +// execution boundary and launches no model, browser, or live product process. +use super::*; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; + +async fn web_body(api: &WorkspaceApi, method: &str, path: &str, body: Option) -> String { + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header( + "authorization", + format!("Bearer api-token-{TEST_WORKSPACE_ID}"), + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.map(|v| v.to_string()).unwrap_or_default())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{method} {path}: {}", + String::from_utf8_lossy(&bytes) + ); + String::from_utf8(bytes.to_vec()).unwrap() +} + +async fn web_pages( + api: &WorkspaceApi, + path: &str, + cursor_field: &str, + query_field: &str, +) -> Vec { + let mut pages = Vec::new(); + let mut next = None::; + loop { + assert!(pages.len() < 10, "pagination failed to terminate"); + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("limit", "1"); + if let Some(cursor) = next.as_deref() { + query.append_pair(query_field, cursor); + } + let separator = if path.ends_with('?') { "" } else { "&" }; + let raw = web_body( + api, + "GET", + &format!("{path}{separator}{}", query.finish()), + None, + ) + .await; + let value: Value = serde_json::from_str(&raw).unwrap(); + next = value[cursor_field].as_str().map(str::to_owned); + pages.push(raw); + if next.is_none() { + break; + } + } + pages +} + +#[tokio::test] +#[ignore = "requires Deno on PATH; deno task test:ticket:real-api selects this bridge explicitly"] +async fn ticket_web_roundtrip_preserves_retained_result_case_history_and_current_answer() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Retain completed research across reissue"); + let assigned = delivery_assign(&api, &ticket.id, "web-initial").await; + let old_id = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let initial = delivery_tracking(&api, &ticket.id, old_id); + delivery_self_report(&api, &identity, &worker, &initial, "done", "web-done").await; + let done = delivery_tracking(&api, &ticket.id, old_id); + let base = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{}", ticket.id); + let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", ticket.id); + let done_detail = web_body(&api, "GET", &detail_path, None).await; + let mut case_details = Vec::new(); + for (key, action) in [ + ( + "web-close", + json!({"action":"close", "reason":"not_planned"}), + ), + ("web-reopen", json!({"action":"reopen"})), + ] { + let observed = api.authority.ticket(&ticket.id).unwrap(); + web_body( + &api, + "POST", + &format!("{base}/case"), + Some(json!({ + "expected_content_digest":observed.content_digest, + "expected_state":observed.state, + "expected_closure_operation_key":observed.closure_operation_key, + "operation_key":key,"action":action,"explanation":"Independent human case decision" + })), + ) + .await; + case_details.push(web_body(&api, "GET", &detail_path, None).await); + assert_eq!(delivery_tracking(&api, &ticket.id, old_id), done); + } + let reassigned = delivery_assign(&api, &ticket.id, "web-reissue").await; + let current_id = reassigned["assignment"]["assignment_id"].as_str().unwrap(); + assert_ne!(old_id, current_id); + let current = delivery_tracking(&api, &ticket.id, current_id); + let reissued_detail = web_body(&api, "GET", &detail_path, None).await; + let retained = web_body( + &api, + "GET", + &format!("{base}/assignment-tracking?assignment_id={old_id}"), + None, + ) + .await; + let assignments = web_pages( + &api, + &format!("{base}/assignment-tracking/assignments?"), + "next_assignment_id", + "after_assignment_id", + ) + .await; + let history = web_pages( + &api, + &format!("{base}/assignment-tracking/history?assignment_id={old_id}"), + "next_event_id", + "after_event_id", + ) + .await; + // Use the actual public read as the Answer input, not the store expectation. + let read = web_body(&api, "GET", &format!("{base}/assignment-tracking"), None).await; + let rows: Value = serde_json::from_str(&read).unwrap(); + let answer_body = json!({"assignment_id":rows[0]["assignment_id"], "expected_last_event_id":rows[0]["last_event_id"], "operation_key":"web-current-answer", "body":"Continue the new request"}); + let receipt = web_body( + &api, + "POST", + &format!("{base}/assignment-answer"), + Some(answer_body.clone()), + ) + .await; + assert_eq!( + web_body( + &api, + "POST", + &format!("{base}/assignment-answer"), + Some(answer_body) + ) + .await, + receipt + ); + let after_answer = web_body(&api, "GET", &detail_path, None).await; + assert_eq!(delivery_tracking(&api, &ticket.id, current_id), current); + let wire = json!({ + "expected":{"workspace_id":TEST_WORKSPACE_ID,"ticket_id":ticket.id, + "old_assignment_id":old_id,"new_assignment_id":current_id, + "done_event_id":done.last_event_id,"new_event_id":current.last_event_id, + "old_result":"Returned options without closing"}, + "done_detail":done_detail,"closed_detail":case_details[0],"reopened_detail":case_details[1], + "reissued_detail":reissued_detail,"retained_old":retained,"assignment_pages":assignments, + "old_history_pages":history,"answer_receipt":receipt,"answered_detail":after_answer + }); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut child = tokio::process::Command::new("deno") + .current_dir(root.join("web/workspace")) + .args([ + "run", + "--cached-only", + "--config", + "test/ticket-real-api/deno.json", + "test/ticket-real-api/roundtrip.ts", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("explicit Ticket bridge requires Deno on PATH"); + // Include stdin backpressure inside the watchdog. Dropping the future kills + // the child on timeout; wait_with_output reaps it on normal completion. + let output = tokio::time::timeout(std::time::Duration::from_secs(30), async move { + let mut stdin = child.stdin.take().unwrap(); + stdin + .write_all(&serde_json::to_vec(&wire).unwrap()) + .await + .unwrap(); + drop(stdin); + child.wait_with_output().await.unwrap() + }) + .await + .expect("Ticket Web bridge exceeded 30 seconds"); + assert!( + output.status.success(), + "Ticket Web bridge failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout) + .contains("real Ticket bytes → production parser/operation/summary lifecycle passed") + ); + execution.take_inputs(); + delivery_stop(&api, &worker); +} diff --git a/deno.json b/deno.json index 7b9ab8d69..d10675008 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "tasks": { "test": "deno run --allow-read --allow-write=target --allow-run --allow-env tools/test.ts", + "test:ticket:real-api": "cargo test -p yoi-workspace-server --lib ticket_web_roundtrip_tests -- --ignored", "test:runner": "deno test --allow-read --allow-write --allow-run --allow-env tools/test.test.ts" }, "fmt": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 7924a15b2..bd5842860 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -12,6 +12,7 @@ "test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", + "test:ticket:real-api": "cd ../.. && deno task test:ticket:real-api", "test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored", "build": "deno run -A npm:vite@8.3.2 build", "preview": "deno run -A npm:vite@8.3.2 preview", diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts index 59b249508..5e4f1e53a 100644 --- a/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts @@ -21,10 +21,10 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail { action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true }, }; } -function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise = () => { throw new Error("Unexpected request"); }) { +function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise = () => { throw new Error("Unexpected request"); }, emptyAssignments = true) { const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = String(input); - if (path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null }); + if (emptyAssignments && path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null }); return await handle(path, init); }); vi.stubGlobal("fetch", fetcher); @@ -104,3 +104,140 @@ test("uncertain assignment reconciliation only refreshes the projection and assi expect(view.queryByRole("alert")).toBeNull(); expectNoDiagnostics(fetcher, view); }); + +const trackingRoot = "/workspaces/team/tickets/internal-T-1/assignment-tracking"; +function historicalDone(action: "replaced" | "unassigned" = "replaced"): TicketAssignmentTrackingProjection { + const row = assigned("done").assignment_tracking[0]; + return { ...row, current: false, release: { + action, actor: "Mika", at: "2026-10-13T10:00:00Z", event_id: "release-1", + replacement_assignment_id: action === "replaced" ? "assignment-2" : null, + last_tracking: row.tracking, + } }; +} + +test("paged retained done history preserves its result and replacement while Answer uses only current waiting observation", async () => { + const old = historicalDone(); + const current = { ...assigned().assignment_tracking[0], assignment_id: "assignment-2", last_event_id: "handling-2", + assigned_at: "2026-10-13T10:00:00Z", tracking: { status: "waiting" as const, waiting_reason: "Choose the new policy" } }; + const ticket = { ...assigned(), assignment_tracking: [current], assignments: [{ ...assigned().assignments[0], assignment_id: current.assignment_id }] }; + const historyEntry = (event_id: string, tracking: TicketAssignmentTrackingProjection["tracking"]) => ({ + event_id, tracking, at: "2026-10-13T09:00:00Z", operation_key: event_id, source_context: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + }); + const fetcher = installFetch((path, init) => { + if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: old.assignment_id }); + if (path === `${assignmentsPath}&after_assignment_id=assignment-1`) return Response.json({ items: [current], next_assignment_id: null }); + if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]); + if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20`) return Response.json({ + items: [historyEntry("old-waiting", { status: "waiting", waiting_reason: "Old question" })], next_event_id: "old-waiting", + }); + if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20&after_event_id=old-waiting`) return Response.json({ + items: [historyEntry("handling-1", old.tracking)], next_event_id: null, + }); + if (path === `${trackingRoot}?assignment_id=assignment-2`) return Response.json([current]); + if (path === `${trackingRoot}/history?assignment_id=assignment-2&limit=20`) return Response.json({ + items: [historyEntry("handling-2", current.tracking)], next_event_id: null, + }); + expect(path).toBe(answerPath); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ assignment_id: "assignment-2", expected_last_event_id: "handling-2", operation_key: expect.any(String), body: "Use the new policy" }); + return Response.json({ assignment_id: "assignment-2", input_event_id: "new-input", event_index: 1 }); + }, false); + const view = mount(ticket); + await fireEvent.click(view.getByText("Past assignment and release history")); + await waitFor(() => expect(view.getByRole("button", { name: "More assignments" })).toBeTruthy()); + await fireEvent.click(view.getByRole("button", { name: "More assignments" })); + await waitFor(() => expect(view.queryByRole("button", { name: "More assignments" })).toBeNull()); + expect(view.getByRole("button", { name: /Current · waiting/ })).toBeTruthy(); + expect(view.getByText(/Comparison delivered/)).toBeTruthy(); + await fireEvent.click(view.getByRole("button", { name: /Past · done/ })); + await waitFor(() => expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy()); + expect(view.getByText(/replaced by Mika/)).toBeTruthy(); + await waitFor(() => expect(view.getByRole("button", { name: "More handling history" })).toBeTruthy()); + await fireEvent.click(view.getByRole("button", { name: "More handling history" })); + await waitFor(() => expect(view.getByText("Result: Comparison delivered")).toBeTruthy()); + expect(view.getByText("Waiting reason: Old question")).toBeTruthy(); + expect(view.queryByRole("button", { name: "More handling history" })).toBeNull(); + expect(view.getByText("Self-reported handling: waiting")).toBeTruthy(); + await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use the new policy" } }); + await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" })); + await waitFor(() => expect(view.getByText(/^Answer saved to assignment assignment-2/)).toBeTruthy()); + expect(view.refresh).toHaveBeenCalledTimes(1); + await fireEvent.click(view.getByRole("button", { name: "Read replacement assignment" })); + await waitFor(() => { + expect(view.getByText(/· current · last handling waiting/)).toBeTruthy(); + expect(view.queryByText("Previous result: Comparison delivered")).toBeNull(); + expect(view.queryByText("Result: Comparison delivered")).toBeNull(); + }); + expectNoDiagnostics(fetcher, view); +}); + +test("released done is retained as past result without current Answer or implicit request", async () => { + const old = historicalDone("unassigned"); + const ticket = { ...assigned(), assignment_tracking: [], assignments: [] }; + const fetcher = installFetch((path) => { + if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: null }); + if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]); + expect(path).toBe(`${trackingRoot}/history?assignment_id=assignment-1&limit=20`); + return Response.json({ items: [], next_event_id: null }); + }, false); + const view = mount(ticket); + await fireEvent.click(view.getByText("Past assignment and release history")); + await waitFor(() => expect(view.getByRole("button", { name: /Past · done/ })).toBeTruthy()); + await fireEvent.click(view.getByRole("button", { name: /Past · done/ })); + await waitFor(() => expect(view.getByText(/unassigned by Mika/)).toBeTruthy()); + expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy(); + expect(view.getByText("No current Orchestrator assignment.")).toBeTruthy(); + expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull(); + expect(view.queryByRole("button", { name: "Read replacement assignment" })).toBeNull(); + expect(view.getByRole("button", { name: "Request Orchestrator" })).toBeTruthy(); + expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === "GET")).toBe(true); + expectNoDiagnostics(fetcher, view); +}); + +test("case close and reopen keep done result non-actionable and require an explicit new assignment", async () => { + const fetcher = installFetch(); + const ticket = assigned("done"); + const view = mount(ticket); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + for (const state of ["closed", "open"] as const) { + await view.rerender({ workspaceId: "team", ticket: { ...ticket, state }, orchestrator: null, refresh: view.refresh }); + expect(view.getByText(`This handling was returned. Ticket ${state} is unchanged.`)).toBeTruthy(); + expect(view.getByText("Comparison delivered")).toBeTruthy(); + expect(view.queryByRole("textbox", { name: "Answer / continue this handling" })).toBeNull(); + expect(view.getByRole("button", { name: "Request again (new assignment)" })).toBeTruthy(); + } + expect(fetcher).toHaveBeenCalledTimes(1); + expect(view.refresh).not.toHaveBeenCalled(); + expectNoDiagnostics(fetcher, view); +}); + +test("releasing responsibility while reading an old result uses current assignment rather than retained identity", async () => { + const old = historicalDone(); + const ticket = assigned(); + ticket.assignment_tracking[0] = { ...ticket.assignment_tracking[0], assignment_id: "assignment-2", last_event_id: "handling-2" }; + ticket.assignments[0] = { ...ticket.assignments[0], assignment_id: "assignment-2" }; + ticket.action_eligibility.can_unassign_orchestrator = true; + vi.stubGlobal("confirm", vi.fn(() => true)); + const fetcher = installFetch((path, init) => { + if (path === assignmentsPath) return Response.json({ items: [old], next_assignment_id: null }); + if (path === `${trackingRoot}?assignment_id=assignment-1`) return Response.json([old]); + if (path === `${trackingRoot}/history?assignment_id=assignment-1&limit=20`) return Response.json({ items: [], next_event_id: null }); + const url = new URL(path, "http://fixture.invalid"); + expect(url.pathname).toBe(assignmentPath); + expect(init?.method).toBe("DELETE"); + expect(url.searchParams.get("assignment_id")).toBe("assignment-2"); + expect(url.searchParams.get("operation_id")).toBeTruthy(); + return Response.json({ workspace_id: "team", ticket_id: "internal-T-1", assignment: null }); + }, false); + const view = mount(ticket); + await fireEvent.click(view.getByText("Past assignment and release history")); + await waitFor(() => expect(view.getByRole("button", { name: /Past · done/ })).toBeTruthy()); + await fireEvent.click(view.getByRole("button", { name: /Past · done/ })); + await waitFor(() => expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy()); + await fireEvent.click(view.getByRole("button", { name: "Release assignment" })); + await waitFor(() => expect(view.getByText("Assignment operation saved: released.")).toBeTruthy()); + expect(view.refresh).toHaveBeenCalledTimes(1); + expect(view.getByText("Previous result: Comparison delivered")).toBeTruthy(); + expect(fetcher.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1); + expectNoDiagnostics(fetcher, view); +}); diff --git a/web/workspace/test/ticket-real-api/README.md b/web/workspace/test/ticket-real-api/README.md new file mode 100644 index 000000000..0be28cfb1 --- /dev/null +++ b/web/workspace/test/ticket-real-api/README.md @@ -0,0 +1,17 @@ +# Ticket実応答のWeb適合 + +`server_ticket_web_roundtrip_tests.rs` が、一時SQLiteと本番routerのHTTP bodyをUTF-8文字列のまま `roundtrip.ts` へ渡す。Webの本番parser・Answer operation・handling summaryを使い、旧done結果、caseのclose/reopen履歴、明示再依頼後の新waiting、retained release、ページ継続、現在のAnswer receiptを確認する。期待する状態と結果はfixtureの操作意図で与え、動的ID/eventは保存正本で別に観測する。応答JSONの再構成や値の補完はしない。 + +## 実行選択 + +rootまたは `web/workspace` で次を実行する。 + +```sh +deno task test:ticket:real-api +``` + +root taskはServerの通常lib targetから `ticket_web_roundtrip_tests` を `--ignored` 付きで明示選択する。DenoがPATHに必要なため通常Cargo実行ではignoredとなる。Deno以外の外部tool、npm取得、network権限、実モデル、稼働Serverは不要。自動jobからもこのtaskを選択する。リポジトリにはCI workflow定義がなく、CIでの実行完了や自動選択は保証しない。 + +一時DBはRAII、子Denoは `kill_on_drop` とstdin書込みを含むwatchdogで隔離・回収する。認証情報やDBパスは子へ渡さず、子のstdout/stderrを失敗診断へ残す。通信transport自体はRust router内であり、Denoは実HTTP bodyの消費者である。browserのfetch・DOMまでつなぐ製品E2Eとは称さない。 + +局所描画・操作は `TicketHandling.browser.spec.ts` の通常component suiteが担当する。こちらは手書きfetch fixtureで履歴選択中も現在のCASへAnswerすること等を確認するため、実Backend適合とは区別する。実session配送・復旧は既存Server delivery suiteに委ね、このbridgeではRuntime実行境界をrecordingに置き換える。 diff --git a/web/workspace/test/ticket-real-api/deno.json b/web/workspace/test/ticket-real-api/deno.json new file mode 100644 index 000000000..6cf0f6b2f --- /dev/null +++ b/web/workspace/test/ticket-real-api/deno.json @@ -0,0 +1,8 @@ +{ + "nodeModulesDir": "none", + "lock": false, + "fmt": { "proseWrap": "never" }, + "imports": { + "#lib/": "../../src/lib/" + } +} diff --git a/web/workspace/test/ticket-real-api/roundtrip.ts b/web/workspace/test/ticket-real-api/roundtrip.ts new file mode 100644 index 000000000..b6a745363 --- /dev/null +++ b/web/workspace/test/ticket-real-api/roundtrip.ts @@ -0,0 +1,352 @@ +import type { TicketAssignmentTrackingProjection } from "#lib/generated/ticket-api.ts"; +import { parseTicketDetail } from "#lib/workspace/api/ticket-browser.ts"; +import { + answerOperation, + parseAnswerReceipt, + parseAssignmentsPage, + parseTracking, + parseTrackingHistory, +} from "#lib/workspace/api/ticket-operations.ts"; +import { + currentOrchestratorAssignment, + handlingSummary, +} from "#lib/workspace/tickets/ticket-panel.ts"; + +// Rust側は隔離DBの本番router応答bodyを再投影せず文字列で渡す。 +// expectedは同じDBのstore再readから取り、Web用期待JSONを複製しない。 +export interface TicketLifecycleFixture { + expected: { + workspace_id: string; + ticket_id: string; + old_assignment_id: string; + new_assignment_id: string; + old_result: string; + done_event_id: string; + new_event_id: string; + }; + done_detail: string; + closed_detail: string; + reopened_detail: string; + reissued_detail: string; + assignment_pages: string[]; + retained_old: string; + old_history_pages: string[]; + answer_receipt: string; + answered_detail: string; +} + +function assert(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, canonical(item)]), + ); + } + return value; +} +function equal(actual: unknown, expected: unknown, message: string) { + assert( + JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)), + `${message}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`, + ); +} +function current(detail: ReturnType) { + const row = currentOrchestratorAssignment(detail); + assert(row, "detail must retain a current Orchestrator assignment"); + return row; +} +function rejectsAnswer(row: TicketAssignmentTrackingProjection) { + let rejected = false; + try { + answerOperation( + row.workspace_id, + row.ticket_id, + row, + "not a request", + "no-send", + ); + } catch { + rejected = true; + } + assert(rejected, "done/past observation must never generate an Answer"); +} + +export function assertTicketLifecycle(fixture: TicketLifecycleFixture) { + const expected = fixture.expected; + const done = parseTicketDetail(JSON.parse(fixture.done_detail)); + const closed = parseTicketDetail(JSON.parse(fixture.closed_detail)); + const reopened = parseTicketDetail(JSON.parse(fixture.reopened_detail)); + const reissued = parseTicketDetail(JSON.parse(fixture.reissued_detail)); + const answered = parseTicketDetail(JSON.parse(fixture.answered_detail)); + assert( + expected.old_result.length > 0, + "fixture must contain an actual old result", + ); + equal(done.id, expected.ticket_id, "detail must identify the real Ticket"); + equal(done.state, "open", "done does not close the case"); + equal(closed.state, "closed", "explicit close is a case decision"); + equal(reopened.state, "open", "explicit reopen is a case decision"); + equal( + closed.closure_history.length, + 1, + "close must remain in public history", + ); + equal( + reopened.closure_history.length, + 2, + "reopen must retain the prior closure", + ); + for (const detail of [done, closed, reopened]) { + const row = current(detail); + equal( + row.workspace_id, + expected.workspace_id, + "workspace scope survives parser", + ); + equal( + row.assignment_id, + expected.old_assignment_id, + "case action is not reissue", + ); + equal( + row.last_event_id, + expected.done_event_id, + "case action is not a handling update", + ); + equal(row.tracking?.status, "done", "case action must not revive handling"); + equal( + row.tracking?.result, + expected.old_result, + "public detail retains the result", + ); + equal( + handlingSummary(row), + expected.old_result, + "production summary uses done result", + ); + rejectsAnswer(row); + } + + const retained = parseTracking(JSON.parse(fixture.retained_old)); + equal( + retained.length, + 1, + "retained read must identify exactly the old assignment", + ); + const old = retained[0]; + equal( + old.assignment_id, + expected.old_assignment_id, + "retained identity survives parser", + ); + equal( + old.current, + false, + "old done is no longer current after explicit reissue", + ); + equal( + old.last_event_id, + expected.done_event_id, + "replacement does not rewrite old handling", + ); + equal(old.tracking?.status, "done", "replacement preserves done"); + equal( + handlingSummary(old), + expected.old_result, + "retained summary keeps the old result", + ); + equal( + old.release?.action, + "replaced", + "replacement is distinct from handling done", + ); + equal( + old.release?.replacement_assignment_id, + expected.new_assignment_id, + "release links the new identity", + ); + rejectsAnswer(old); + + assert( + fixture.assignment_pages.length >= 2, + "exercise the real assignment page cursor", + ); + const pages = fixture.assignment_pages.map((body) => + parseAssignmentsPage(JSON.parse(body)) + ); + for (let index = 0; index < pages.length; index++) { + const page = pages[index]; + if (index < pages.length - 1) { + assert( + page.next_assignment_id, + "nonterminal assignment page must expose its cursor", + ); + equal( + page.next_assignment_id, + page.items.at(-1)?.assignment_id, + "cursor binds the last assignment", + ); + } else { + assert( + page.next_assignment_id == null, + "last assignment page must terminate", + ); + } + } + const rows = pages.flatMap((page) => page.items); + equal(rows.length, 2, "traversal retains both old and new delegations"); + equal( + new Set(rows.map((row) => row.assignment_id)).size, + 2, + "assignment pages do not duplicate rows", + ); + equal( + rows.find((row) => row.assignment_id === old.assignment_id), + old, + "page and retained projection agree", + ); + const next = current(reissued); + equal( + next.assignment_id, + expected.new_assignment_id, + "explicit request creates a new current identity", + ); + assert( + next.assignment_id !== old.assignment_id, + "reissue must not reuse done identity", + ); + equal( + next.last_event_id, + expected.new_event_id, + "new current observation survives parser", + ); + equal( + next.tracking?.status, + "waiting", + "new handling starts waiting, not old done", + ); + assert( + next.tracking?.result == null && next.release == null, + "new row must not inherit result/release", + ); + equal( + handlingSummary(next), + next.tracking?.waiting_reason ?? "Waiting reason not reported", + "new summary never borrows historical result", + ); + equal( + rows.find((row) => row.assignment_id === next.assignment_id), + next, + "page and current detail agree", + ); + equal(reissued.state, "open", "reissue keeps the case open"); + equal( + reissued.closure_history, + reopened.closure_history, + "reissue does not change case history", + ); + + assert( + fixture.old_history_pages.length >= 2, + "exercise the real handling page cursor", + ); + const histories = fixture.old_history_pages.map((body) => + parseTrackingHistory(JSON.parse(body)) + ); + for (let index = 0; index < histories.length; index++) { + const page = histories[index]; + if (index < histories.length - 1) { + assert( + page.next_event_id, + "nonterminal history page must expose its cursor", + ); + equal( + page.next_event_id, + page.items.at(-1)?.event_id, + "history cursor binds the last event", + ); + } else { + assert(page.next_event_id == null, "last history page must terminate"); + } + } + const events = histories.flatMap((page) => page.items); + equal(events.length, 2, "old waiting and done history survive replacement"); + equal( + new Set(events.map((entry) => entry.event_id)).size, + 2, + "history pages do not duplicate events", + ); + equal( + events[0].tracking.status, + "waiting", + "initial handling is not overwritten", + ); + equal( + events[1].event_id, + expected.done_event_id, + "history retains last observed event", + ); + equal( + events[1].tracking.result, + expected.old_result, + "history parser retains old result", + ); + + const operation = answerOperation( + expected.workspace_id, + expected.ticket_id, + next, + "Continue the new request", + "web-current-answer", + ); + equal(operation.method, "POST", "production Answer method"); + equal( + operation.path, + `/api/w/${encodeURIComponent(expected.workspace_id)}/tickets/${ + encodeURIComponent(expected.ticket_id) + }/assignment-answer`, + "production Answer target", + ); + equal(JSON.parse(operation.body!), { + assignment_id: expected.new_assignment_id, + expected_last_event_id: expected.new_event_id, + operation_key: "web-current-answer", + body: "Continue the new request", + }, "production Answer uses only the actionable current CAS"); + const receipt = parseAnswerReceipt(JSON.parse(fixture.answer_receipt)); + equal( + receipt.assignment_id, + next.assignment_id, + "real saved Answer belongs to new delegation", + ); + assert( + receipt.input_event_id.length > 0, + "real Answer receipt exposes saved input", + ); + equal( + current(answered), + next, + "Answer must not infer inprogress or replace waiting", + ); + equal(answered.state, reissued.state, "Answer does not change case state"); + equal( + answered.closure_history, + reissued.closure_history, + "Answer preserves closure history", + ); +} + +if (import.meta.main) { + // パイプだけを入力境界とし、認証情報・実DBパス・HTTP接続は要求しない。 + const fixture: TicketLifecycleFixture = JSON.parse( + await new Response(Deno.stdin.readable).text(), + ); + assertTicketLifecycle(fixture); + console.log( + "real Ticket bytes → production parser/operation/summary lifecycle passed", + ); +}