test: connect Ticket public regressions to real Backend
This commit is contained in:
@@ -619,11 +619,34 @@ async fn assignment_delivery_rendered_content_is_immutable_across_restart_and_co
|
||||
|
||||
#[test]
|
||||
fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history() {
|
||||
use crate::authority::{SqliteWorkspaceAuthority, TicketAuthority};
|
||||
use server_api::{TicketQueryRequest, TicketShowRequest};
|
||||
use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug};
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("schema92.db");
|
||||
// A genuine retained Ticket-10/Workspace-91 fixture goes through its actual
|
||||
// applied tracking migration; no current DB markers or columns are lowered.
|
||||
let conn = tracking_schema91_fixture(&path);
|
||||
// Public browse needs the resource-key rows a real old Workspace already
|
||||
// stored. Seed that old input before migration, never patch its projection.
|
||||
let ids: Vec<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();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 92);
|
||||
let assignments = stored_fixture_rows(&conn, "ticket_worker_assignments");
|
||||
@@ -668,6 +691,176 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let principal = ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
};
|
||||
for (id, status, legacy_state) in [
|
||||
(
|
||||
"pending",
|
||||
Some(TicketAssignmentTrackingStatus::Waiting),
|
||||
"queued",
|
||||
),
|
||||
(
|
||||
"progress",
|
||||
Some(TicketAssignmentTrackingStatus::Inprogress),
|
||||
"inprogress",
|
||||
),
|
||||
("closed-reopen", None, "queued"),
|
||||
("historical-done", None, "done"),
|
||||
] {
|
||||
let stored = reopened
|
||||
.get_ticket_assignment_tracking("w", id, &format!("a-{id}"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.tracking.as_ref().map(|t| t.status), status, "{id}");
|
||||
assert_eq!(stored.principal, principal, "{id}");
|
||||
assert!(stored.current, "{id}");
|
||||
assert!(stored.last_event_id.is_some(), "{id}");
|
||||
assert!(stored.migration_diagnostic.is_some(), "{id}");
|
||||
let detail = authority.ticket(id).unwrap();
|
||||
assert_eq!(detail.assignment_tracking, vec![stored], "{id}");
|
||||
assert_eq!(
|
||||
authority.ticket_assignment_tracking(id).unwrap(),
|
||||
detail.assignment_tracking,
|
||||
"{id}"
|
||||
);
|
||||
let shown = authority
|
||||
.show_ticket(id, TicketShowRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
shown.assignment_tracking, detail.assignment_tracking,
|
||||
"{id}"
|
||||
);
|
||||
let saved_ticket = backend.show(TicketIdOrSlug::Id(id.into())).unwrap();
|
||||
assert_eq!(
|
||||
detail.state.as_str(),
|
||||
saved_ticket.meta.workflow_state.as_str(),
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(detail.closure_history, saved_ticket.case_history, "{id}");
|
||||
assert_eq!(
|
||||
detail.legacy_handoff, saved_ticket.legacy_workflow_snapshot,
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(
|
||||
detail.legacy_handoff.as_ref().unwrap().workflow_state,
|
||||
legacy_state,
|
||||
"{id}"
|
||||
);
|
||||
assert_eq!(shown.closure_history, detail.closure_history, "{id}");
|
||||
assert_eq!(shown.legacy_handoff, detail.legacy_handoff, "{id}");
|
||||
assert_eq!(
|
||||
detail.state.as_str(),
|
||||
if id == "historical-done" {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
},
|
||||
"{id}"
|
||||
);
|
||||
if id == "closed-reopen" {
|
||||
assert_eq!(detail.closure_history.len(), 2);
|
||||
assert_eq!(
|
||||
detail.closure_history[0].action,
|
||||
ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detail.closure_history[1].action,
|
||||
ticket::TicketCaseAction::Reopen
|
||||
);
|
||||
}
|
||||
}
|
||||
let assigned = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
assigned_principal: Some(principal.clone()),
|
||||
limit: Some(100),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut assigned_ids = assigned
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<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]
|
||||
|
||||
@@ -36926,6 +36926,9 @@ mod db_execution_tests;
|
||||
mod tests {
|
||||
mod assignment_delivery_tests {
|
||||
include!("server_assignment_delivery_tests.rs");
|
||||
mod ticket_web_roundtrip_tests {
|
||||
include!("server_ticket_web_roundtrip_tests.rs");
|
||||
}
|
||||
}
|
||||
|
||||
mod assignment_start_tests {
|
||||
@@ -36933,6 +36936,10 @@ mod tests {
|
||||
}
|
||||
mod assignment_tracking_tests {
|
||||
include!("server_assignment_tracking_tests.rs");
|
||||
mod tools_backend_tests {
|
||||
use super::*;
|
||||
include!("server_ticket_tools_backend_tests.rs");
|
||||
}
|
||||
}
|
||||
|
||||
mod ticket_case_signed_tests {
|
||||
|
||||
@@ -319,6 +319,39 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del
|
||||
delivery_stop(&api, &worker);
|
||||
}
|
||||
|
||||
async fn delivery_public_assignment_snapshot(
|
||||
api: &WorkspaceApi,
|
||||
ticket: &str,
|
||||
assignment: &str,
|
||||
) -> (Value, Value, Value) {
|
||||
let root = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}");
|
||||
let detail = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let retained = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("{root}/assignment-tracking?assignment_id={assignment}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let history = delivery_http(
|
||||
api,
|
||||
"GET",
|
||||
&format!("{root}/assignment-tracking/history?assignment_id={assignment}&limit=100"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
(detail, retained, history)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -331,6 +364,25 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
let initial = delivery_tracking(&api, &ticket.id, assignment);
|
||||
delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await;
|
||||
let done = delivery_tracking(&api, &ticket.id, assignment);
|
||||
let (done_detail, done_public, done_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
let expected_done = serde_json::to_value(&done).unwrap();
|
||||
assert_eq!(
|
||||
expected_done["tracking"]["result"],
|
||||
"Returned options without closing"
|
||||
);
|
||||
assert_eq!(done_detail["state"], "open");
|
||||
assert_eq!(done_detail["assignment_tracking"], json!([expected_done]));
|
||||
assert_eq!(done_public, done_detail["assignment_tracking"]);
|
||||
let history_items = done_history["items"].as_array().unwrap();
|
||||
assert_eq!(history_items.len(), 2, "initial waiting and reported done");
|
||||
assert_eq!(history_items[0]["tracking"]["status"], "waiting");
|
||||
assert_eq!(
|
||||
history_items[1]["event_id"],
|
||||
done.last_event_id.as_deref().unwrap()
|
||||
);
|
||||
assert_eq!(history_items[1]["tracking"], expected_done["tracking"]);
|
||||
assert!(done_history["next_event_id"].is_null());
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
|
||||
"open"
|
||||
@@ -373,6 +425,29 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
"case history must not revive done delegation"
|
||||
);
|
||||
assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done);
|
||||
let (detail, public, history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
assert_eq!(
|
||||
detail["state"],
|
||||
if key == "close-return" {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detail["closure_history"].as_array().unwrap().len(),
|
||||
if key == "close-return" { 1 } else { 2 }
|
||||
);
|
||||
assert_eq!(
|
||||
detail["assignment_tracking"],
|
||||
done_detail["assignment_tracking"]
|
||||
);
|
||||
assert_eq!(public, done_public, "case decision rewrote public handling");
|
||||
assert_eq!(
|
||||
history, done_history,
|
||||
"case decision rewrote handling history"
|
||||
);
|
||||
}
|
||||
let reassigned = delivery_assign(&api, &ticket.id, "reissue-return").await;
|
||||
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -395,6 +470,69 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
||||
.status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
let (detail, old_public, old_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, assignment).await;
|
||||
let (_, new_public, new_history) =
|
||||
delivery_public_assignment_snapshot(&api, &ticket.id, new_assignment).await;
|
||||
assert_eq!(detail["state"], "open");
|
||||
assert_eq!(detail["closure_history"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
old_public,
|
||||
json!([serde_json::to_value(&retained).unwrap()])
|
||||
);
|
||||
assert_eq!(old_public[0]["tracking"], expected_done["tracking"]);
|
||||
assert_eq!(
|
||||
old_public[0]["last_event_id"],
|
||||
expected_done["last_event_id"]
|
||||
);
|
||||
assert_eq!(old_public[0]["release"]["action"], "replaced");
|
||||
assert_eq!(
|
||||
old_public[0]["release"]["replacement_assignment_id"],
|
||||
new_assignment
|
||||
);
|
||||
assert_eq!(old_public[0]["release"]["operation_key"], "reissue-return");
|
||||
assert_eq!(old_history, done_history, "reissue changed old history");
|
||||
assert_eq!(detail["assignment_tracking"], new_public);
|
||||
assert_eq!(new_public[0]["assignment_id"], new_assignment);
|
||||
assert_eq!(new_public[0]["current"], true);
|
||||
assert_eq!(new_public[0]["tracking"]["status"], "waiting");
|
||||
assert!(new_public[0]["tracking"]["result"].is_null());
|
||||
assert!(new_public[0]["release"].is_null());
|
||||
assert_eq!(new_history["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
new_history["items"][0]["event_id"],
|
||||
new_public[0]["last_event_id"]
|
||||
);
|
||||
// Follow the public cursor rather than assuming current-first ordering.
|
||||
let page_root = format!(
|
||||
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/assignment-tracking/assignments",
|
||||
ticket.id
|
||||
);
|
||||
let first = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("{page_root}?limit=1"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let cursor = first["next_assignment_id"]
|
||||
.as_str()
|
||||
.expect("two assignments need a cursor");
|
||||
let second = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("{page_root}?limit=1&after_assignment_id={cursor}"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert!(second["next_assignment_id"].is_null());
|
||||
assert_eq!(first["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(second["items"].as_array().unwrap().len(), 1);
|
||||
let rows = [&first["items"][0], &second["items"][0]];
|
||||
assert!(rows.contains(&&old_public[0]));
|
||||
assert!(rows.contains(&&new_public[0]));
|
||||
// An exact old Assign retry returns its receipt, never revives old work.
|
||||
assert_eq!(
|
||||
delivery_assign(&api, &ticket.id, "assign-return").await,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user