test: integrate Ticket public regression coverage
This commit is contained in:
@@ -619,11 +619,34 @@ async fn assignment_delivery_rendered_content_is_immutable_across_restart_and_co
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history() {
|
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 directory = tempfile::tempdir().unwrap();
|
||||||
let path = directory.path().join("schema92.db");
|
let path = directory.path().join("schema92.db");
|
||||||
// A genuine retained Ticket-10/Workspace-91 fixture goes through its actual
|
// A genuine retained Ticket-10/Workspace-91 fixture goes through its actual
|
||||||
// applied tracking migration; no current DB markers or columns are lowered.
|
// applied tracking migration; no current DB markers or columns are lowered.
|
||||||
let conn = tracking_schema91_fixture(&path);
|
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<String> = conn
|
||||||
|
.prepare("SELECT ticket_id FROM typed_tickets ORDER BY ticket_id")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| r.get(0))
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<_, _>>()
|
||||||
|
.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();
|
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
|
||||||
assert_eq!(current_schema_version(&conn).unwrap(), 92);
|
assert_eq!(current_schema_version(&conn).unwrap(), 92);
|
||||||
let assignments = stored_fixture_rows(&conn, "ticket_worker_assignments");
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -36933,6 +36933,9 @@ mod db_execution_tests;
|
|||||||
mod tests {
|
mod tests {
|
||||||
mod assignment_delivery_tests {
|
mod assignment_delivery_tests {
|
||||||
include!("server_assignment_delivery_tests.rs");
|
include!("server_assignment_delivery_tests.rs");
|
||||||
|
mod ticket_web_roundtrip_tests {
|
||||||
|
include!("server_ticket_web_roundtrip_tests.rs");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod assignment_start_tests {
|
mod assignment_start_tests {
|
||||||
@@ -36940,6 +36943,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
mod assignment_tracking_tests {
|
mod assignment_tracking_tests {
|
||||||
include!("server_assignment_tracking_tests.rs");
|
include!("server_assignment_tracking_tests.rs");
|
||||||
|
mod tools_backend_tests {
|
||||||
|
use super::*;
|
||||||
|
include!("server_ticket_tools_backend_tests.rs");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod ticket_case_signed_tests {
|
mod ticket_case_signed_tests {
|
||||||
|
|||||||
@@ -319,6 +319,39 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del
|
|||||||
delivery_stop(&api, &worker);
|
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]
|
#[tokio::test]
|
||||||
async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() {
|
async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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);
|
let initial = delivery_tracking(&api, &ticket.id, assignment);
|
||||||
delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await;
|
delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await;
|
||||||
let done = delivery_tracking(&api, &ticket.id, assignment);
|
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!(
|
assert_eq!(
|
||||||
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
|
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
|
||||||
"open"
|
"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"
|
"case history must not revive done delegation"
|
||||||
);
|
);
|
||||||
assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done);
|
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 reassigned = delivery_assign(&api, &ticket.id, "reissue-return").await;
|
||||||
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
|
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,
|
.status,
|
||||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
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.
|
// An exact old Assign retry returns its receipt, never revives old work.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
delivery_assign(&api, &ticket.id, "assign-return").await,
|
delivery_assign(&api, &ticket.id, "assign-return").await,
|
||||||
|
|||||||
@@ -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<RuntimeIdentityMaterial>,
|
||||||
|
source: RuntimeWorkerRef,
|
||||||
|
runtime: tokio::runtime::Handle,
|
||||||
|
lose_next_response: AtomicBool,
|
||||||
|
requests: Mutex<Vec<(String, u16, String)>>,
|
||||||
|
}
|
||||||
|
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<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
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<dyn agen::llm_client::LlmClient> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
_: agen::llm_client::Request,
|
||||||
|
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
|
||||||
|
{
|
||||||
|
panic!("Ticket tools fixture must not call an LLM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn execute_tool(
|
||||||
|
tool: Arc<dyn Tool>,
|
||||||
|
input: serde_json::Value,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
) -> std::result::Result<serde_json::Value, ToolError> {
|
||||||
|
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<String, Arc<dyn Tool>>,
|
||||||
|
wip: bool,
|
||||||
|
}
|
||||||
|
impl TicketSurface {
|
||||||
|
async fn new(root: &Path, client: Arc<TicketRouterClient>, 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<serde_json::Value, ToolError> {
|
||||||
|
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<TicketRouterClient> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
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::<Vec<_>>()
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Value>) -> 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<String> {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
let mut next = None::<String>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run --allow-read --allow-write=target --allow-run --allow-env tools/test.ts",
|
"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"
|
"test:runner": "deno test --allow-read --allow-write --allow-run --allow-env tools/test.test.ts"
|
||||||
},
|
},
|
||||||
"fmt": {
|
"fmt": {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Ticketの接続回帰テスト設計
|
||||||
|
|
||||||
|
T-744の設計判断。公開入口で観測した値を次の操作へ渡せることを優先し、保存・配送の既存保証を一巡E2Eで再実装しない。一般則は[テスト設計](rust-testing-strategy.md)・[検証](validation.md)に従う。
|
||||||
|
|
||||||
|
以下の提案・未確認・試行値は設計時点の記録であり、現在の実装完了を表さない。恒久化したTools/WIP適合は `crates/workspace-server/src/server_ticket_tools_backend_tests.rs`、Web bytes適合の実行選択と代替境界は [Ticket実応答のWeb適合](../../web/workspace/test/ticket-real-api/README.md) が所有する。lifecycleと移行後公開readは下記の既存suite内で強化し、実session配送のsuiteは重複させない。
|
||||||
|
|
||||||
|
## 対象sourceと判断の前提
|
||||||
|
|
||||||
|
- 調査・実行source: `main@develop` の `10d200d9ba8bc313bb7f72eabafc279c9def3cf5`、tree `546e6b517b1a82dd90f2e48f758b1364147dd6e3`。cleanな専用checkoutから `work/T-744-ticket-test-design` を作成した。
|
||||||
|
- 改稿基準: `hare/develop@adfa42297352c5a64fe8100969aad6fda4df8a81`。`git merge-base --is-ancestor 10d200d9 adfa4229` は成功。`git diff 10d200d9 adfa4229` は15 files、260追加/206削除。追加履歴は `b1808b7c` とmerge `adfa4229`。
|
||||||
|
- 差分は `builtin-source/src/lib.rs`、`manifest/src/profile.rs`、`merge-request/src/lib.rs`、Workerの `feature/builtin/merge_request.rs`・`prompt/{catalog,system}.rs`、Serverの `authority.rs`・`server.rs`・`server/tests/ticket_evidence_tests.rs`、resourcesの `flows/coder-review.dcdl`・`profiles/orchestrator.dcdl`・`prompts/common/merge-request.md`・`prompts/role/{coder,orchestrator,reviewer}.md`(Rustパスは `crates/` 起点)。主にclose後のMR follow-upでretained assignmentを使う変更、認可、Prompt簡素化。develop側のactive assignment条件と同一視しない。
|
||||||
|
- 以下の主証拠であるassignment_public、assignment tracking/delivery、implementation result、migration、Webのファイルは両sourceで同一。ただし共通 `server.rs` とauthorityの差分があるため、実行結果は上記developだけのもの。他branchの変更・最適化を取り込んでいない。
|
||||||
|
- 2026-10-17にShowTicketでT-744現行本文(digest `7c943b0d2e1ee7decffaa3f4414c519d929466e6dba8ba9dfaef8149e639982f`)、T-743/T-752〜755の現行証拠、T-761/T-725本文を確認。T-743の公開面、T-752の共通routing、T-753/755のMR・返却接続と診断は既存成果として再利用する。T-754のJob全体は本設計で再設計しない。T-761は本設計の優先順位・重複・コストを横断整理する。T-725はユーザー保留のまま、Drive共有を含めない。
|
||||||
|
|
||||||
|
以下 `WS/` = `crates/workspace-server/src/`、`WT/` = `crates/worker/src/feature/builtin/ticket/`、`WEB/` = `web/workspace/src/lib/workspace/`。テスト名は対象sourceの証拠を特定するためのもので、一般仕様一覧ではない。
|
||||||
|
|
||||||
|
## 採否の要約
|
||||||
|
|
||||||
|
| 優先 | 利用結果/具体的故障 | 判定と後続単位 |
|
||||||
|
| ------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P1 A | Repositoryなしで担当を読み、その観測IDで同じ委任へAnswer。readのID欠落・route差・投影差で次操作が拒否される | **新規が必要**。既存Server targetに通常Tools/WIPと実Backendの薄い適合結合。局所CAS/入力表は再作成しない |
|
||||||
|
| P1 B | 実装assignment/MRなしで結果返却。Hostのretry keyが実Backendで別操作になる/結果と通知sourceが分離する | **HTTP→保存→実sessionは既存で充足**。**Tools/WIP→同HTTPの適合だけ新規が必要**。Aとtransportを共有し、実Worker二重起動を避ける |
|
||||||
|
| P2 C | doneの結果、置換・case履歴を読み直してWebに表示。currentとpastの取り違え、parser拒否、旧結果の表示欠落 | **既存を強化+薄いadapter結合が必要**。Server公開readの観測追加、Web履歴component assertion、実Backend bytes→production parserを分ける |
|
||||||
|
| P2 D | 旧schema移行・再open後もunknownを依頼にせず検索・読取りできる | **既存を強化**。migration→配送対象は既存充足、同DB→authority公開read/queryへの接続だけ補う |
|
||||||
|
| 維持 E | 保存→配送→永続session、ACK喪失・復旧・並行scanで重複しない | **既存で充足**。既存実Workerケースを維持し、recording backendを実session証拠へ格上げしない |
|
||||||
|
| 対象外 | 実LLMの判断品質、任意OS crash、Drive共有、close後のMR処理を両branchで統一 | **仕様確認/別scopeが先**。機構テストで自然言語判断・電源断耐性を主張しない。MR差分はT-761/Orchestratorへsource差として返す |
|
||||||
|
|
||||||
|
## 既存証拠と限界
|
||||||
|
|
||||||
|
| 証拠(file / test名) | 実際のassertion・本番区間と代替境界 |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `WT/assignment_public/tests.rs` / `continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt`、`public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity`、`assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs` | 同assignment/event/key/bodyの転送、保存receipt、403/409/unknownで自動retryなし、WIP route束縛と入力偽装拒否。**固定応答Transport**であり実router・storeのJSONとの適合ではない。`assignment_tracking.rs`のreadも同じ限界 |
|
||||||
|
| `WS/server_assignment_tracking_tests.rs` / `public_assignment_answer_authenticates_source_replays_saved_result_and_rejects_read_reassign_race` | 公開HTTPの認証、Answerイベント1件・認証actor・本文、tracking不変、exact replay、異なるpayload/stale/actor偽装の拒否。SQLite/routerは本番。readの観測値はhelperから取得し、通常Tool/WIPの出力を次操作に渡してはいない |
|
||||||
|
| 同file / `same_principal_reassignment_retains_old_result_and_projects_replacement_identity` | signed HTTPの旧assignment readで `current=false`、done/result保持、`release.action=replaced`・後継ID、次assignmentはwaiting・resultなし。fresh DB起点。履歴ページは `tracking_history_is_assignment_scoped_and_paged_and_rejects_unknown_cursors` が担当 |
|
||||||
|
| `WS/server_assignment_delivery_tests.rs` / `assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation` | typed close/reopen→scanで入力なし、done不変、明示reissueは新IDで入力1件、旧結果保持、旧Assign replayで再実行なし。**recording backend**の入力観測。公開detail/historyの再照合やDOMまでの証拠ではない |
|
||||||
|
| 同file / `assignment_real_worker_response_loss_restore_and_concurrent_scans_commit_once` | 実Worker受付後に応答だけを落とす。DB再open・同Worker復元・title/self-report変更後も同request ID/本文、同一通知commit1回、8並行scanの新Answerは1配送、tracking不変。`delivery_receipts` は永続sessionのactivation receipt、`delivery_commits` はcanonical notification。LLM・応答喪失に加えて起動・復元factoryもfixture(`DeliveryWorkerFactory`)。WorkerBootstrap/session/receiptは本番だが、`ProfileRuntimeWorkerFactory`や復元後Workspace client認証接続の証拠ではない。Answer準備はstore直呼びなのでAを代替しない |
|
||||||
|
| `WS/server_implementation_result_tests.rs` / `implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr` | 実装assignment/MRなしのsigned HTTP→結果照会receipt一致→実Orchestrator session commit、exact replay、異payload/別actor衝突、偽MR拒否、公開診断runtime_accepted・処理確認false、再scanでreceipt増加なし、case/handling不変。Orchestrator委任はあり、LLMは固定。`mr_result_recovery_and_handoff_are_distinct_persistent_inputs` はMRイベントとhandoffを別入力として確認。通知未接続を改めて追加する必要なし |
|
||||||
|
| `WS/implementation_result_tests.rs` / `implementation_result_notification_failure_rolls_back_and_concurrent_retries_share_receipt`、`implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence` | 実DBのsource insert失敗で結果0・業務状態不変、並行retry receipt共有。旧JSON bytes・ACK・failure・制約・rollbackを保持、再open照会/replayで通知再captureなし。Workerを使わない保存保証。移行fixtureは旧schemaを再構成したものなので、真の古いDB全体からの更新とは区別 |
|
||||||
|
| `WS/assignment_tracking_tests.rs` / `tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receipts_and_claims` | 実旧schema91 fixture→migration、pending/progressだけwaiting/inprogress、invalid-fence/closed-reopen/replaced/historical-doneはtrackingなし。原assignment・監査・receipt・claim保持、DB再open後の履歴一致 |
|
||||||
|
| `WS/assignment_delivery_tests.rs` / `assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history` | 同旧fixtureから最新storeへopen。配送対象はpending/progressのみ、input IDはmigration tracking由来、保存行同値・FK違反0。**migration→配送対象の不足という旧想定は採用しない** |
|
||||||
|
| `WS/store.rs` / `schema_v89_runs_auth_then_cases_preserving_legacy_states_evidence_and_responsibility`・`schema_v90_cases_preserve_auth_archive_legacy_states_evidence_and_responsibility`、`crates/ticket/tests/migration.rs` / `standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference` | 旧done/closedをClosed+Unknownへ保存、旧snapshot/evidence・case履歴を保持、reopenで成功やactive assignment復活を推論しない。Workspace helperは再openも確認。公開query/Webとの接続とは別 |
|
||||||
|
| `WS/server/tests/ticket_evidence_tests.rs` / `case_route_preserves_corrected_history_replay_and_reopen_aba_guard_without_mr` | 公開detailのcurrent_closure/history/keyが保存値と一致、訂正で旧判断不変、reopen・replay・古いclosure keyのABA拒否。`WS/ticket_browse_tests.rs` / `ticket_browse_all_filters_are_applied_before_limit_and_batch_fields_match` はbrowse/detailのtracking・closure一致、`ticket_browse_assignment_current_missing_unknown_and_cleared_semantics` は過去doneを現在検索へ混ぜない。移行DB起点ではない |
|
||||||
|
| `WEB/api/ticket-operations.test.ts` / `waiting answers carry the same assignment and observed event without status or Assign mutation`、`retained assignment page preserves result and release rather than rewriting handling to done` | Answer inputの同一ID/event、done/null観測を拒否、releaseとhandlingの分離。`api/ticket-browser.test.ts` の `Ticket Browser keeps self-reported delegation independent of case and Worker state` と `tags named ready and closed coexist with case judgment and retained reopen history without inferring state` はparserの値保持。手書きAPI fixtureでありBackend適合ではない |
|
||||||
|
| `WEB/tickets/ticket-operations.browser.spec.ts` / `an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes`、`a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded` | happy-dom上でfetchをfake、再render後もexact bytes維持、保存済み+refresh失敗を配送成功と表示しない。`TicketHandling.browser.spec.ts` の `answer save keeps handling unchanged and refreshes history without diagnostics reads` はwaiting維持。**実HTTP/認証/Backend適合・実browserの証拠ではない** |
|
||||||
|
|
||||||
|
## 必要な変更の具体形
|
||||||
|
|
||||||
|
### A: 公開readの観測をそのままAnswerへ渡す(P1)
|
||||||
|
|
||||||
|
- **配置・最小fixture:** `WS/server/tests/ticket_public_roundtrip_tests.rs` を既存Server lib targetの子moduleにする。`delivery_recording_fixture`相当の一時SQLite・登録Runtime/Worker・署名identity、Repository/WorkdirなしTicket1件、Orchestrator waiting1件。共有支援は既存test module内へ置き、private helperのために製品APIを広げない。
|
||||||
|
- **入口:** 通常側は `Worker::new`+NoLlm・空scope・一時FsStoreへ `TicketFeature` を `install_features` し、`engine().tool_server_handle().flush_pending()/get_tool` で実登録Toolを呼ぶ(構築は `server_workspace_config_wip_tests.rs`、Tool取得は `server/tests/worker_drive_tests.rs::native_tools` の既存形を再利用)。WIP側は公開 `mount_workspace_http_ticket_wip`+`WipRuntime::from_mounts/invoke`。Queryの実出力からassignment_id/last_event_idを取り、Answerへ渡す。`/tickets/<ref>` のrouteはWIPが束縛し、別Ticket入力を注入しない。
|
||||||
|
- **本番区間/fake:** Tool/WIP投影→署名WorkspaceClient adapter→production router/middleware→実store。adapterは `worker_drive_tests.rs::RouterClient` と同様、毎要求を署名してrouterへ送り、固定JSONを返さない。Runtime実行側はrecording、LLMは未呼出し。sync clientは別threadでrouterを駆動し、Tokio runtime内の入れ子block_onを避ける。
|
||||||
|
- **faultと観測:** 実read応答の `last_event_id` 脱落、Answerのassignment ID改変、commit後の成功応答喪失を各境界で独立に注入。Toolの公開出力がDB projectionのID/eventに等しいこと、receiptのassignment ID、保存Ticketのassignment_answerイベント本文・認証actor・1件、tracking/委任行不変を再readでassertする。staleは409相当、unknownはエラーのまま、自動別key送信なし。同一操作を明示retryして同receipt/イベント1件。
|
||||||
|
- **自己通知と外部入力を分離:** 現在Orchestratorが自分でAnswerした場合、保存は成功しても自己通知抑止で新入力0が正しい。外部ユーザーAnswerの配送はEの既存実sessionケースへ任せ、ここに新Worker/sessionを足さない。UIからの外部AnswerはCの公開API適合対象。
|
||||||
|
- **期待検出:** IDが欠ければ観測一致assertionまたはAnswer入力検証が失敗する。DB helperの値で補完して成功させない。正常操作後のstale表や認証行列は既存APIテストへ委ねる。
|
||||||
|
- **実行・コスト:** 通常 `cargo test -p yoi-workspace-server --lib ticket_public_roundtrip`(提案filter)。WIP最小probeは実測2.37秒、通常Tools追加分は未測定で概ね既存DB fixture+小Worker構築1回分。保持資源はtempdir/DB、課金・外部サービスなし。A/Bで同じtransport/署名/cleanupを所有し、既存巨大targetを増やさない。
|
||||||
|
|
||||||
|
### B: assignment/MR不要の返却ToolのBackend適合だけ足す(P1)
|
||||||
|
|
||||||
|
- **配置・準備/入口:** Aと同suiteに別test `implementation_result_tool_and_wip_keep_host_owned_retry_identity`(提案)。同じ実DB/署名adapter、実装assignmentもMRも持たない登録Worker1件。通知先のOrchestrator委任も不要な状態から開始する。通常ToolとWIP Invokeから `TicketImplementationResult` → Query。kind/bodyだけをモデル入力にし、operation keyは現行 `ToolExecutionContext.call_id` とTicket参照から固定する。contextにsession fieldはなく、`batch_id`/`call_index`は冪等性keyに使わない。Backend側でWorkspaceと認証Workerに束縛するため、session単位の冪等性保証とは称さない。
|
||||||
|
- **fault/読取り/assertion:** commit後responseを一度落として同じcontext+全入力で明示retry。公開エラーがunknownを成功へ変換せず、実storeの結果・notification sourceが各1件、同result_id/receipt、Query本文一致、case/handling/assignments不変、sourceはno_recipientの抑止で後のAssignへ遡及配送されない。後者の局所保存規則は既存 `implementation_result_without_any_assignment_is_authenticated_and_never_retargeted` が実DB・再open・no_recipient/pendingなしを確認済みで、その表を再実装せず公開入口の代表だけ追加する。異contextは別報告、同contextの異bodyは衝突。Host key生成と実Backendのoperation key/actor bindingのずれを検出する。source insert失敗rollbackは既存store試験を再実装しない。
|
||||||
|
- **本番/fake・残る不足:** 通常Tools/WIP・Host key生成・署名router・実storeは本番、Runtimeはrecordingでよい。session到達は既存signed HTTP試験が担当。Tool試験だけから実session到達と称さず、両証拠を組にする。この追加は未試行。既存 `implementation_result_transport_key_uses_only_trusted_invocation_identity`・`implementation_result_wip_and_native_dispatch_preserve_trusted_call_context` は固定Transportの局所証拠として維持する。
|
||||||
|
- **実行/コスト:** 通常、Aと同target/filter。1 DB fixtureの数秒程度を推定、実Worker startup9秒級を再度増やさない。保守点はHost contextと本番認証bindingの更新に限定する。
|
||||||
|
|
||||||
|
### C: lifecycleの公開履歴とWeb表示をつなぐ(P2)
|
||||||
|
|
||||||
|
- **Server強化:** `assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation` に、typed close/reopen直後とreissue後のHTTP detail・retained assignment・historyページの再readを追加。元結果/更新ID・close判断履歴が保持され、currentは新ID/waiting、旧IDはdone/replacedであることをassert。既存scan入力なし/明示reissue1件のassertionも保持。fakeはrecording Runtimeだけ。case reopenでAssign/Worker起動は増えないことをstoreのassignment数と記録入力で照合する。新しい一巡testは不要。
|
||||||
|
- **Web局所強化:** `TicketHandling.browser.spec.ts` にcurrent waitingとpast done/replaced+resultの最小2行、history2ページを追加。`Past assignment and release history`から旧assignmentを選び、Previous result・release actor/action・後継IDを表示、Moreでevent順を保持、current AnswerのIDを旧IDに替えないことをassert。faultは `TicketHandling.svelte` のretained result/release表示を外す、historyのcursor/assignment bindingを外す位置。`ticket-browser.test.ts`のclosure履歴値保持は再利用し、巨大snapshotを増やさない。このcomponentはAPI fakeの表示保証に限る。
|
||||||
|
- **実Backend適合:** 同Server suiteのresponse bytes(detail、assignments page、retained row、history、Answer receipt、case後のdetail)を改変せずTSへ渡し、`parseTicketDetail/parseAssignmentsPage/parseTrackingHistory/parseAnswerReceipt` とproduction `answerOperation`・`ticket-panel.ts::handlingSummary`を使う。両側で期待JSONを手書きしない。old/new result・release・closure historyがparser後も同じ意味、doneはAnswer不可、waitingは観測CASを生成するとassert。faultはBackend公開projectionの旧結果脱落/current誤り、parser側でresultを捨てる位置。公開HTTPの認可は既存signed/browser route testsへ委ねる。
|
||||||
|
- **配置/実行:** Server観測追加は通常lib、提案filter `assignment_done_historical_inputs`。componentは通常 `cd web/workspace && deno task test:component -- src/lib/workspace/tickets/TicketHandling.browser.spec.ts`。TS bridgeは `web/workspace/test/ticket-real-api/` とServer子module(提案 `ticket_web_roundtrip_tests`)へ、既存Driveのsubprocess管理方式のみ再利用しDrive資源を作らない。外部Denoが必要なので `cargo test -p yoi-workspace-server --lib ticket_web_roundtrip -- --ignored` の明示target/jobとする。恒久化時に実行選択を配線する。現時点でCI実行済みの主張はしない。
|
||||||
|
- **コスト/未確認:** parser bridgeの一時probeはwaiting1状態で0.137秒、Backend準備はAに相乗り。lifecycle全snapshot、historyページ、componentの新assertionは未試行。追加見込みはDB観測の数query+Deno起動(概ね1秒以内を推定)、componentはrunner起動費用が別。実Backend→DOM全接続や実browserを確認した証拠ではない。まずparser適合と局所描画を分け、残るDOM接続を理由なく巨大E2Eで埋めない。
|
||||||
|
|
||||||
|
### D: 旧DBから公開read/queryへ出る代表を補う(P2)
|
||||||
|
|
||||||
|
- **配置/fixture/入口:** `assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history` の既存真のschema91 fixtureを再利用。最新へmigration/open後、storeをdropして再openし、同DBの `SqliteWorkspaceAuthority` から公開 `ticket/show_ticket`・`query_tickets` を読む。constructor/contextは `ticket_browse_tests.rs::Fixture` の形を共有する。実Workerや全旧状態×全画面行列は作らない。
|
||||||
|
- **最小状態/期待値:** pending=waiting、progress=inprogress、closed-reopenまたはhistorical-done=trackingなしを代表にする。公開rowのassignment_id/last_event_id/migration_diagnostic、case/legacy履歴がstoreと一致。queryのprincipal+handling filterでwaitingとinprogressを区別し、unknownはどちらにもdoneにも該当せず、limit=1でも漏れない。現行の配送対象pending/progressだけというassertionを保持し、public read/query後も候補集合不変。旧case移行のClosed+Unknownはstore既存で充足、必要なら同helper末尾へ公開detailの一観測だけ追加する。
|
||||||
|
- **fault/読取り:** authorityのprojectionでtrackingなしをwaitingへ補う/queryが履歴doneを拾う/配送候補が旧履歴を再採用する位置。公開API型の値と同DBのstore read、pending候補を読取り元にする。fakeは旧保存入力だけ、migration/store/authority/queryは本番。queryと配送対象を互いの期待値生成元にせず、既存fixtureの意味に対して各々assertする。
|
||||||
|
- **実行/コスト:** 通常 `cargo test -p yoi-workspace-server --lib assignment_delivery_schema92_upgrade`。既存本体1.91秒、tracking migration2.04秒を実測。追加は同DB再openと少数query(秒未満〜数秒を推定)、旧DBをケースごとに再構築しない。強化部分の実行・mutationは未試行。typed close/reopen+reissueの意味はC/Eが既に担い、このmigration fixtureで全一巡を追加しない。
|
||||||
|
|
||||||
|
## 代表試行・実測と残る未確認
|
||||||
|
|
||||||
|
2026-10-17、このcheckoutで `-j 2`、稼働Server/Runtime・外部モデルを使わず実行した。以下は単発のホスト測定で、CI・中央値・T-761のhare/develop測定ではない。
|
||||||
|
|
||||||
|
| 試行 | 結果/本体時間(外側buildは別) |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 既存 `implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr` | 1 passed、9.61秒。初回外側build4分55秒、全コマンド5分05秒 |
|
||||||
|
| 既存 `assignment_real_worker_response_loss_restore_and_concurrent_scans_commit_once` | 実験復元後1 passed、14.58秒。再build1分27秒は実行時間に含めない |
|
||||||
|
| 既存 `assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation` | 1 passed、2.41秒 |
|
||||||
|
| 既存 `assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history` / `tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receipts_and_claims` | 各1 passed、1.91 / 2.04秒 |
|
||||||
|
| 既存 `same_principal_reassignment_retains_old_result_and_projects_replacement_identity` / `case_route_preserves_corrected_history_replay_and_reopen_aba_guard_without_mr` / `public_assignment_answer_authenticates_source_replays_saved_result_and_rejects_read_reassign_race` | 各1 passed、0.54 / 0.66 / 0.72秒 |
|
||||||
|
| `api/ticket-{browser,case,operations}.test.ts` | 32 passed、test合計147ms、Deno外側0.867秒 |
|
||||||
|
| Aの一時WIP probe | 本番mount→invoke→毎要求署名router→実SQLite。実readからID/eventをAnswerへ渡し、同receipt replay・保存イベント1件・waiting不変・自己入力0を確認。単独1 passed、2.37秒(外側build48.94秒) |
|
||||||
|
| Aのfault probe | 上記と同じ実router応答からlast_event_idだけをtransport境界で削除。正常1 passed+fault側1 failed、3.49秒。`read[0].last_event_id == store.last_event_id` がNull対Stringで意図どおり失敗(コマンドexit101)。Backend自体のprojectionを書き換えたmutationではなく、公開応答脱落の境界faultである |
|
||||||
|
| Cの一時parser probe | 上記DBの実HTTP detail/pageとWIP Answer receiptをファイル経由でproduction Web parserへ渡し、case=open/waiting・ID一致、次Answerの観測CAS、event欠落時の操作拒否を確認。成功、Deno全体0.137秒。DOM/HTTP fetch adapter全体は未試行 |
|
||||||
|
|
||||||
|
一時probeは小fixtureで接続とassertionを試すためだけに作成した。初版の非公開install API呼出し・入れ子runtimeはfixture構築失敗、IDがTicket文字列を含むという誤期待と自己Answerの配送1件という誤期待はprobeの誤りとして修正した。いずれも製品不具合/回帰検出成功とは数えない。通常Tools側は公開Worker install/Tool取得を使う上記設計までで、未試行である。
|
||||||
|
|
||||||
|
試行用source/includeは復元し、最終tracked treeは文書だけ。ローカルの `target/t744-experiments/` にprobe source・Backendの合成fixture応答・ログ・一時include diffを保持するが、共有成果や維持対象fixtureとして参照しない。恒久テスト・製品改修・migration変更は含めない。再現時はAの手順で小probeを作り、実router応答の単一field脱落だけで失敗するかを確認する。
|
||||||
|
|
||||||
|
**未実行:** Aの通常Tools/response-loss/stale強化、BのTool/WIP実Backend結合、Cのlifecycle全履歴bridge/新DOM assertion、Dの公開query強化、Web component/browser新規実行、全通常suite、実モデル判断試行。既存ケースの成功をこれらの成功へ拡張しない。仕様確認が必要になった場合の確認先は、Answer/self-suppressionはT-742/T-752、返却の非依存・Host identityはT-753現行Tools契約、case/retained表示はT-739/T-741/T-743、移行unknownはT-739/T-741の保存契約。変更要求はT-744 Orchestratorへ返し、古いTicket本文のassignment必須記述を現行返却APIへ逆輸入しない。
|
||||||
@@ -13,6 +13,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: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: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: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",
|
"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",
|
"build": "deno run -A npm:vite@8.3.2 build",
|
||||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
|
|||||||
action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true },
|
action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }) {
|
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }, emptyAssignments = true) {
|
||||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const path = String(input);
|
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);
|
return await handle(path, init);
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetcher);
|
vi.stubGlobal("fetch", fetcher);
|
||||||
@@ -104,3 +104,140 @@ test("uncertain assignment reconciliation only refreshes the projection and assi
|
|||||||
expect(view.queryByRole("alert")).toBeNull();
|
expect(view.queryByRole("alert")).toBeNull();
|
||||||
expectNoDiagnostics(fetcher, view);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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に置き換える。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"nodeModulesDir": "none",
|
||||||
|
"lock": false,
|
||||||
|
"fmt": { "proseWrap": "never" },
|
||||||
|
"imports": {
|
||||||
|
"#lib/": "../../src/lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof parseTicketDetail>) {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user