fix: simplify Ticket reporting and speed up browser lists

Remove model-supplied assignment and retry identities from implementation reports; retain Host idempotency, authentication and notification evidence.

Add a SQL-only browser projection and decouple auxiliary page loads. Preserve authoritative MR evidence queries. Include regression tests and component benchmarks, documenting the remaining no-match search cost.
This commit is contained in:
2026-10-16 01:58:15 +09:00
parent 9f1cbbd385
commit b67aa2de7d
42 changed files with 3197 additions and 152 deletions
+13 -2
View File
@@ -9,6 +9,8 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
mod drive;
mod notification;
pub use notification::*;
mod ticket_browse;
pub use ticket_browse::*;
pub mod repository_openapi_typescript;
pub use drive::*;
mod workspace_config;
@@ -376,6 +378,8 @@ impl_openapi_schema!(
TicketAssignmentDiagnosticsPage,
TicketQueryRequest,
TicketQueryResponse,
TicketBrowseQueryRequest,
TicketBrowseQueryResponse,
TicketQueueResponse,
TicketRecord,
TicketRecordRef,
@@ -3033,6 +3037,14 @@ pub trait ServerApi {
#[body] request: TicketTagsSetRequest,
) -> Result<WorkspaceTicketTagList, RepositoryApiError>;
/// Lightweight browser list; deliberately excludes live MR evidence and external observations.
#[post("/api/w/{workspace_id}/tickets/browse", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_browse(
&self,
#[path] workspace_id: String,
#[body] request: TicketBrowseQueryRequest,
) -> Result<TicketBrowseQueryResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_query(
&self,
@@ -8058,9 +8070,8 @@ pub enum TicketImplementationResultKind {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketImplementationResultRequest {
/// Host-owned replay identity; model-facing tools derive it from the Tool call.
pub operation_key: String,
pub assignment_id: String,
pub orchestrator_assignment_id: String,
pub kind: TicketImplementationResultKind,
pub body: String,
pub merge_request: Option<MergeRequestReviewSubject>,
@@ -459,6 +459,13 @@ fn validate_ticket_browser_operations(
request: Some("TicketTagSetRequest"),
successes: &[("200", "Array_of_WorkspaceTag")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/browse",
method: "post",
operation_id: "ticket_browse",
request: Some("TicketBrowseQueryRequest"),
successes: &[("200", "TicketBrowseQueryResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/query",
method: "post",
+65
View File
@@ -0,0 +1,65 @@
//! Lightweight Tickets browser contract, separate from authoritative evidence queries.
use crate::{BrowserTicketState, QueryPage};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketBrowseQueryRequest {
pub query: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))]
pub states: Vec<ticket::TicketState>,
/// Every supplied active tag ID must match (AND).
#[serde(default)]
pub tag_ids: Vec<String>,
#[cfg_attr(
feature = "typescript",
ts(type = "TicketAssignmentPrincipalSnapshot | null")
)]
pub assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>,
#[cfg_attr(
feature = "typescript",
ts(type = "TicketAssignmentTrackingStatus | null")
)]
pub handling_status: Option<ticket::TicketAssignmentTrackingStatus>,
/// Exact current closed-case reason; historical reasons do not match this filter.
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))]
pub closure_reason: Option<ticket::TicketClosureReason>,
/// Only updated_desc is supported; defaults to updated_desc.
pub sort: Option<String>,
#[schemars(range(min = 0, max = 1000))]
pub limit: Option<usize>,
/// Browse-only cursor bound to the Workspace, canonical filters, and sort.
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketBrowseQueryItem {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: BrowserTicketState,
pub updated_at: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
#[cfg_attr(
feature = "typescript",
ts(type = "Array<TicketAssignmentTrackingProjection>")
)]
pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketBrowseQueryResponse {
pub items: Vec<TicketBrowseQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
@@ -91,8 +91,9 @@ struct AnswerInput {
#[serde(deny_unknown_fields)]
struct ImplementationResultInput {
ticket: String,
#[serde(flatten)]
request: server_api::TicketImplementationResultRequest,
kind: server_api::TicketImplementationResultKind,
body: String,
merge_request: Option<server_api::MergeRequestReviewSubject>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -157,7 +158,7 @@ pub(super) fn description(name: &str) -> &'static str {
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
}
"TicketImplementationResult" => {
"Save an explicit handoff, result or blocker for the current implementation assignment and its original Orchestrator delegation. Read ShowTicket first for assignment identities. Backend authenticates the implementation Worker and fences both assignments; this is not AssignmentAnswer or a handling update. Optional MR/source identifies retained evidence, never approval. Retry an unknown response using exactly the same operation_key and full input; changed input with the same key is rejected. Result and durable notification source commit together. No case closure, new Assign, Worker stop or integration occurs."
"Report facts as the authenticated active Workspace Worker for this Ticket: an explicit handoff, result or blocker with kind, bounded body and optional retained MR/source evidence. No current or pinned implementation assignment or Orchestrator delegation match is required; do not supply assignment identities or actor claims. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent; a new invocation is a new report. If the outcome is uncertain, reconcile saved evidence rather than blindly submitting a new invocation. This is not AssignmentAnswer, a handling update or approval. Result and durable notification source commit together. No case closure, new Assign, Worker stop or integration occurs."
}
"TicketImplementationResultQuery" => {
"Read one saved implementation result by Ticket and result_id from a result notification/receipt. Historical evidence is not a new request or approval; reread current delegation and MR before acting."
@@ -196,7 +197,11 @@ struct PublicAssignmentTool {
}
#[async_trait]
impl Tool for PublicAssignmentTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
async fn execute(
&self,
input: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
@@ -282,31 +287,34 @@ impl Tool for PublicAssignmentTool {
let input: ImplementationResultInput =
serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
for (name, value) in [
("assignment_id", &input.request.assignment_id),
(
"orchestrator_assignment_id",
&input.request.orchestrator_assignment_id,
),
("operation_key", &input.request.operation_key),
] {
identity(name, value)?;
}
if input.request.body.trim().is_empty()
|| input.request.body.len() > 16384
|| input.request.body.contains('\0')
// Retry identity belongs to the Host, never model input. Missing trusted
// context must fail closed rather than minting a replacement identity.
identity("tool call_id", &ctx.call_id)?;
let operation_key = format!(
"ticket-implementation-result:{}:{}",
input.ticket, ctx.call_id
);
identity("operation_key", &operation_key)?;
if input.body.trim().is_empty()
|| input.body.len() > 16384
|| input.body.contains('\0')
{
return Err(ToolError::InvalidArgument(
"Result body must be nonblank, NUL-free and at most 16384 bytes".into(),
));
}
if let Some(mr) = &input.request.merge_request {
if let Some(mr) = &input.merge_request {
identity("merge_request_id", &mr.merge_request_id)?;
identity("subject_ref", &mr.subject_ref)?;
}
WorkspaceServerOperation::TicketImplementationResult {
ticket_id: input.ticket,
request: input.request,
request: server_api::TicketImplementationResultRequest {
operation_key,
kind: input.kind,
body: input.body,
merge_request: input.merge_request,
},
}
}
PublicAssignmentKind::ImplementationResultQuery => {
@@ -59,13 +59,13 @@ fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
),
(
PublicAssignmentKind::ImplementationResult,
json!({"ticket":"T-743","assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
json!({"ticket":"T-743","kind":"result","body":"Research without an MR","merge_request":null}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
),
(
PublicAssignmentKind::ImplementationResultQuery,
json!({"ticket":"T-743","result_id":"result-id"}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
),
(
PublicAssignmentKind::Answer,
@@ -175,6 +175,203 @@ async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_with
}
}
}
#[tokio::test]
async fn implementation_result_schema_exposes_only_report_facts_and_rejects_forged_fields() {
let (kind, input, response) = cases().remove(4);
let client = transport(200, response);
let (meta, tool) = definition(client.clone(), kind)();
let properties = meta.input_schema["properties"].as_object().unwrap();
assert_eq!(properties.len(), 4);
for field in ["ticket", "kind", "body", "merge_request"] {
assert!(properties.contains_key(field), "missing {field}");
}
assert_eq!(meta.input_schema["additionalProperties"], false);
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let mut minimal = input.clone();
minimal.as_object_mut().unwrap().remove("merge_request");
assert!(schema.is_valid(&minimal));
assert!(serde_json::from_value::<ImplementationResultInput>(minimal).is_ok());
for field in [
"operation_key",
"assignment_id",
"orchestrator_assignment_id",
"result_id",
"call_id",
"request",
"actor",
"runtime_id",
"worker_id",
] {
let mut forged = input.clone();
forged[field] = json!("forged");
assert!(!schema.is_valid(&forged), "schema accepts {field}");
assert!(serde_json::from_value::<ImplementationResultInput>(forged.clone()).is_err());
assert!(matches!(
tool.execute(&forged.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
let query = PublicAssignmentKind::ImplementationResultQuery.schema();
assert!(query["properties"].get("result_id").is_some());
assert!(
query["required"]
.as_array()
.unwrap()
.contains(&json!("result_id"))
);
}
#[tokio::test]
async fn implementation_result_transport_key_uses_only_trusted_invocation_identity() {
let (kind, input, response) = cases().remove(4);
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for ctx in [
ToolExecutionContext::new("call-a", "batch-a", 0),
ToolExecutionContext::new("call-a", "batch-b", 7),
ToolExecutionContext::new("call-b", "batch-a", 0),
] {
tool.execute(&input.to_string(), ctx).await.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(requests[0], requests[1]);
assert_ne!(requests[0], requests[2]);
for (request, call_id) in requests.iter().zip(["call-a", "call-a", "call-b"]) {
assert_eq!(request.method, WorkspaceRequestMethod::Post);
assert!(
request
.path
.ends_with("/tickets/T-743/implementation-results")
);
let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let mut expected = input.clone();
expected.as_object_mut().unwrap().remove("ticket");
expected["operation_key"] = json!(format!("ticket-implementation-result:T-743:{call_id}"));
assert_eq!(body, expected);
assert!(body.get("assignment_id").is_none());
assert!(body.get("orchestrator_assignment_id").is_none());
}
}
#[tokio::test]
async fn implementation_result_missing_or_invalid_context_fails_before_dispatch() {
let (kind, input, response) = cases().remove(4);
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for call_id in [
String::new(),
" \t".into(),
"call\nforged".into(),
"x".repeat(1025),
] {
let error = tool
.execute(
&input.to_string(),
ToolExecutionContext::new(call_id, "batch", 0),
)
.await
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)));
assert!(error.to_string().contains("tool call_id"));
}
// A valid call ID must also fit the bounded transport key after namespacing.
assert!(matches!(
tool.execute(
&input.to_string(),
ToolExecutionContext::new("x".repeat(1024), "batch", 0),
)
.await,
Err(ToolError::InvalidArgument(_))
));
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn implementation_result_validates_report_body_ticket_kind_and_optional_mr() {
let (kind, input, response) = cases().remove(4);
let client = transport(200, response);
let (meta, tool) = definition(client.clone(), kind)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let mut invalid_inputs = vec![];
for body in [
String::new(),
" \n".into(),
"x".repeat(16385),
"text\0hidden".into(),
"é".repeat(8193),
] {
let mut invalid = input.clone();
invalid["body"] = json!(body);
invalid_inputs.push(invalid);
}
for value in [String::new(), " \t".into(), "x\n".into(), "x".repeat(1025)] {
let mut invalid = input.clone();
invalid["ticket"] = json!(value);
invalid_inputs.push(invalid);
for field in ["merge_request_id", "subject_ref"] {
let mut invalid = input.clone();
invalid["merge_request"] = json!({"merge_request_id":"MR-1","subject_ref":"source"});
invalid["merge_request"][field] = json!(value);
invalid_inputs.push(invalid);
}
}
for value in [json!("done"), json!(null), json!(42)] {
let mut invalid = input.clone();
invalid["kind"] = value;
assert!(!schema.is_valid(&invalid));
invalid_inputs.push(invalid);
}
for mr in [
json!({"merge_request_id":"MR-1"}),
json!({"merge_request_id":"MR-1","subject_ref":"source","operation_key":"forged"}),
] {
let mut invalid = input.clone();
invalid["merge_request"] = mr;
assert!(!schema.is_valid(&invalid));
invalid_inputs.push(invalid);
}
for invalid in invalid_inputs {
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
for (index, report_kind) in ["handoff", "result", "blocker"].into_iter().enumerate() {
let mut valid = input.clone();
valid["kind"] = json!(report_kind);
valid["body"] = json!("é".repeat(8192));
if index == 0 {
valid.as_object_mut().unwrap().remove("merge_request");
} else if index == 2 {
valid["merge_request"] = json!({"merge_request_id":"MR-1","subject_ref":"source"});
}
assert!(schema.is_valid(&valid));
tool.execute(
&valid.to_string(),
ToolExecutionContext::new(format!("call-{index}"), "batch", index),
)
.await
.unwrap();
}
assert_eq!(client.requests.lock().unwrap().len(), 3);
}
#[test]
fn implementation_result_guidance_keeps_retry_identity_host_owned() {
let description = description("TicketImplementationResult");
assert!(description.contains("authenticated active Workspace Worker"));
assert!(description.contains("Host owns transport retry identity"));
assert!(description.contains("a new invocation is a new report"));
assert!(description.contains("rather than blindly submitting"));
assert!(!description.contains("operation_key"));
assert!(!description.contains("fences both assignments"));
}
#[tokio::test]
async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() {
let (kind, input, response) = cases().pop().unwrap();
@@ -511,6 +708,105 @@ async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() {
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn implementation_result_wip_and_native_dispatch_preserve_trusted_call_context() {
let (kind, input, response) = cases().remove(4);
let client = transport(200, response);
let (_, direct_tool) = definition(client.clone(), kind)();
direct_tool
.execute(
&input.to_string(),
ToolExecutionContext::new("call-a", "native", 0),
)
.await
.unwrap();
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args: BTreeMap<_, _> = input
.as_object()
.unwrap()
.iter()
.filter(|(key, _)| key.as_str() != "ticket")
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
for call_id in ["call-a", "call-b"] {
handler
.call(
"implementation_result",
&args,
WipCallContext {
execution: ToolExecutionContext::new(call_id, "wip", 3),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP implementation_result rejected {call_id}"));
}
{
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(requests[0], requests[1]);
assert_ne!(requests[1], requests[2]);
let body: Value = serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
assert_eq!(
body["operation_key"],
"ticket-implementation-result:T-743:call-b"
);
}
assert!(matches!(
handler
.call(
"implementation_result",
&args,
WipCallContext {
execution: ToolExecutionContext::new("", "wip", 0),
security_context: "worker".into(),
},
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::InvalidArguments,
..
}))
));
for field in [
"operation_key",
"assignment_id",
"orchestrator_assignment_id",
"call_id",
] {
let mut forged = args.clone();
forged.insert(field.into(), WipValue::String("forged".into()));
assert!(matches!(
handler
.call(
"implementation_result",
&forged,
WipCallContext {
execution: ToolExecutionContext::new("call-c", "wip", 0),
security_context: "worker".into(),
},
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::InvalidArguments,
..
}))
));
}
assert_eq!(client.requests.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() {
for (kind, mut input, response) in cases() {
+1 -1
View File
@@ -29,7 +29,7 @@ server-api.workspace = true
project-record.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
ring.workspace = true
rusqlite.workspace = true
rusqlite = { workspace = true, features = ["functions"] }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_yaml.workspace = true
@@ -471,7 +471,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
"Common notification routing and durable Worker delivery".into()
),
(96,"Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into())
(97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
+3
View File
@@ -50,6 +50,9 @@ use crate::store::{
};
use crate::{Error, Result};
#[path = "ticket_browse.rs"]
mod ticket_browse;
const DETAIL_BODY_LIMIT: usize = 64 * 1024;
const TICKET_EVENT_LIMIT: usize = 100;
const TICKET_EVENT_BODY_LIMIT: usize = 16 * 1024;
@@ -1,6 +1,18 @@
//! Implementation results are authenticated facts, not Orchestrator answers or handling updates.
//! The write lock fences the original implementation assignment and its pinned delegation.
//! Replays return the original result after reassignment, but cannot authorize a new result.
//! Reports belong to their authenticated Workspace Worker and Ticket, not an assignment.
//! Replays preserve the original result and notification recipient even after reassignment.
fn decode_result_request(raw: &str) -> Result<server_api::TicketImplementationResultRequest> {
// Retained pre-v98 requests keep their original bytes for audit. Assignment
// fields are historical metadata, not inputs or authorization for a new report.
let mut value: serde_json::Value =
serde_json::from_str(raw).map_err(|e| Error::Store(e.to_string()))?;
if let Some(object) = value.as_object_mut() {
object.remove("assignment_id");
object.remove("orchestrator_assignment_id");
}
serde_json::from_value(value).map_err(|e| Error::Store(e.to_string()))
}
use super::*;
impl SqliteWorkspaceStore {
@@ -13,7 +25,7 @@ impl SqliteWorkspaceStore {
self.with_conn(|conn| {
let saved: Option<(String,String)> = conn.query_row("SELECT request_json,created_at FROM ticket_implementation_results WHERE workspace_id=?1 AND ticket_id=?2 AND result_id=?3",params![workspace,ticket,result],|r| Ok((r.get(0)?,r.get(1)?))).optional()?;
let (request,created_at) = saved.ok_or_else(|| Error::InvalidInput("Implementation result not found in this Ticket".into()))?;
Ok(server_api::TicketImplementationResultReceipt { result_id: result.into(), ticket_id: ticket.into(), request: serde_json::from_str(&request).map_err(|e| Error::Store(e.to_string()))?, created_at })
Ok(server_api::TicketImplementationResultReceipt { result_id: result.into(), ticket_id: ticket.into(), request: decode_result_request(&request)?, created_at })
})
}
pub(crate) fn record_implementation_result(
@@ -24,14 +36,7 @@ impl SqliteWorkspaceStore {
worker: &RuntimeWorkerRef,
binding: &str,
) -> Result<server_api::TicketImplementationResultReceipt> {
for (name, value) in [
("operation_key", &request.operation_key),
("assignment_id", &request.assignment_id),
(
"orchestrator_assignment_id",
&request.orchestrator_assignment_id,
),
] {
for (name, value) in [("operation_key", &request.operation_key)] {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control)
{
return Err(Error::InvalidInput(format!(
@@ -50,23 +55,19 @@ impl SqliteWorkspaceStore {
let encoded = serde_json::to_string(request).map_err(|e| Error::Store(e.to_string()))?;
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let authenticated: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2 AND binding_id=?3 AND revoked_at IS NULL)", params![workspace,worker.runtime_id,binding], |r| r.get(0))?;
let authenticated: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings b JOIN worker_registry w ON w.workspace_id=b.workspace_id AND w.runtime_id=b.runtime_id WHERE b.workspace_id=?1 AND b.runtime_id=?2 AND b.binding_id=?3 AND b.revoked_at IS NULL AND w.worker_id=?4)", params![workspace,worker.runtime_id,binding,worker.worker_id], |r| r.get(0))?;
if !authenticated {
return Err(Error::WorkspacePermissionDenied("Implementation result requires its authenticated Runtime binding".into()));
}
let saved: Option<(String,String,String,String,String,String)> = tx.query_row("SELECT result_id,ticket_id,request_json,runtime_id,worker_id,created_at FROM ticket_implementation_results WHERE workspace_id=?1 AND operation_key=?2",params![workspace,request.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?;
if let Some((result_id,saved_ticket,saved_request,runtime,source,created_at)) = saved {
if saved_ticket != ticket || saved_request != encoded || runtime != worker.runtime_id || source != worker.worker_id {
if saved_ticket != ticket || decode_result_request(&saved_request)? != *request || runtime != worker.runtime_id || source != worker.worker_id {
return Err(Error::TicketAssignmentConflict("Implementation result operation key was used for different input or source".into()));
}
return Ok(server_api::TicketImplementationResultReceipt { result_id, ticket_id: ticket.into(), request: request.clone(), created_at });
}
let owns: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_implementation_delegations d ON d.workspace_id=c.workspace_id AND d.assignment_id=c.assignment_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.role='worker' AND c.assignment_id=?3 AND c.principal_kind='worker' AND c.runtime_id=?4 AND c.worker_id=?5 AND d.orchestrator_assignment_id=?6 AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=c.workspace_id AND r.assignment_id=c.assignment_id))",params![workspace,ticket,request.assignment_id,worker.runtime_id,worker.worker_id,request.orchestrator_assignment_id],|r|r.get(0))?;
if !owns {
return Err(Error::WorkspacePermissionDenied("Result source is not the current implementation Worker for this delegation; legacy unknown work requires explicit assignment confirmation".into()));
}
// A delayed new result is rejected rather than retargeted to a new Orchestrator.
notification_routing::require_current(&tx,workspace,ticket,&request.orchestrator_assignment_id,"orchestrator")?;
let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2)",params![workspace,ticket],|r|r.get(0))?;
if !exists { return Err(Error::InvalidInput("Result Ticket not found in this Workspace".into())); }
if let Some(mr) = &request.merge_request {
// References identify retained evidence, not a model's assertion that a branch
// is approved/current. Integration must resolve Git and reread the latest MR.
@@ -75,7 +76,7 @@ impl SqliteWorkspaceStore {
}
let result_id = uuid::Uuid::now_v7().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
tx.execute("INSERT INTO ticket_implementation_results VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace,ticket,request.operation_key,result_id,request.assignment_id,request.orchestrator_assignment_id,worker.runtime_id,worker.worker_id,encoded,created_at])?;
tx.execute("INSERT INTO ticket_implementation_results(workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![workspace,ticket,request.operation_key,result_id,worker.runtime_id,worker.worker_id,encoded,created_at])?;
tx.commit()?;
Ok(server_api::TicketImplementationResultReceipt { result_id, ticket_id: ticket.into(), request: request.clone(), created_at })
})
@@ -0,0 +1,21 @@
-- v98: result reports are Worker/Ticket facts, not assignment-completion operations.
-- Preserve legacy request bytes and already-captured notification evidence verbatim.
DROP TRIGGER notification_implementation_result;
DROP TRIGGER implementation_delegation_capture;
CREATE TABLE ticket_implementation_results_v98 (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, operation_key TEXT NOT NULL,
result_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, request_json TEXT NOT NULL CHECK(json_valid(request_json)),
created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,operation_key), UNIQUE(workspace_id,result_id),
FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id)
);
INSERT INTO ticket_implementation_results_v98
SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at
FROM ticket_implementation_results;
DROP TABLE ticket_implementation_results;
ALTER TABLE ticket_implementation_results_v98 RENAME TO ticket_implementation_results;
CREATE TRIGGER implementation_results_no_update BEFORE UPDATE ON ticket_implementation_results BEGIN SELECT RAISE(ABORT,'Implementation results are immutable'); END;
CREATE TRIGGER implementation_results_no_delete BEFORE DELETE ON ticket_implementation_results BEGIN SELECT RAISE(ABORT,'Implementation results are immutable'); END;
-- ticket_implementation_delegations remains read-only historical evidence only.
-- It is no longer populated or consulted by result reporting.
@@ -3,14 +3,198 @@
fn implementation_result_input() -> server_api::TicketImplementationResultRequest {
server_api::TicketImplementationResultRequest {
operation_key: "result-operation".into(),
assignment_id: "implementation".into(),
orchestrator_assignment_id: "delegation".into(),
kind: server_api::TicketImplementationResultKind::Result,
body: "Research complete; no repository was required".into(),
merge_request: None,
}
}
fn implementation_result_business_state(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
[
"typed_tickets",
"typed_ticket_events",
"typed_ticket_event_attributes",
"ticket_worker_assignments",
"ticket_current_worker_assignments",
"ticket_worker_assignment_events",
"typed_ticket_assignment_tracking_history",
"ticket_assignment_work_releases",
"worker_control_grants",
"ticket_implementation_delegations",
]
.map(|table| fixture_query_rows(conn, &format!("SELECT * FROM {table} ORDER BY rowid")))
.into_iter()
.collect()
}
fn implementation_result_notification_evidence(
conn: &Connection,
) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
[
"notification_sources",
"notification_decisions",
"notification_requests",
"notification_failures",
"notification_acknowledgements",
"notification_assignment_targets",
"ticket_assignment_deliveries",
"ticket_assignment_delivery_requests",
"ticket_assignment_delivery_acknowledgements",
]
.map(|table| stored_fixture_rows(conn, table))
.into_iter()
.collect()
}
#[tokio::test]
async fn implementation_result_without_any_assignment_is_authenticated_and_never_retargeted() {
let (dir, store) = tracking_store_fixture().await;
let worker = RuntimeWorkerRef::new("r", "replacement");
let input = implementation_result_input();
let before = store.with_conn(|conn| {
// Retention holds are not reporting permissions.
conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?;
assert_eq!(current_schema_version(conn)?, 98);
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0);
Ok(implementation_result_business_state(conn))
}).unwrap();
let receipt = store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap();
assert_eq!(store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap(), before);
assert_eq!(store.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
// A second, independently authenticated Worker cannot reuse this Workspace operation key.
let other = RuntimeWorkerRef::new("r", "worker");
assert!(store.record_implementation_result("w", "tracking", &input, &other, "tracking-binding").is_err());
let mut other_input = input.clone();
other_input.operation_key = "other-worker-result".into();
store.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding").unwrap();
seed_manual_start_ticket(&store, "other-ticket", "open");
assert!(store.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding").is_err());
let mut missing = input.clone();
missing.operation_key = "missing-ticket-result".into();
assert!(store.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding").is_err());
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
.any(|p| p.input_event_id.starts_with("implementation-result:")));
let evidence = store.with_conn(|conn| {
let source: (String, String, String) = conn.query_row(
"SELECT s.assignment_id,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1",
[format!("implementation-result:{}", receipt.result_id)], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
assert_eq!(source, ("".into(), "suppress".into(), "no_recipient".into()));
Ok((
fixture_query_rows(conn, "SELECT * FROM notification_sources WHERE kind='implementation_result' ORDER BY sequence"),
fixture_query_rows(conn, "SELECT d.* FROM notification_decisions d JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.kind='implementation_result' ORDER BY s.sequence"),
))
}).unwrap();
// A later Orchestrator must not acquire previously recipient-less reports.
tracking_store_assign(&store, "late-delegation", "assign-late");
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
.any(|p| p.input_event_id.starts_with("implementation-result:")));
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 2);
// Assignment creation has its own evidence; old report evidence stays byte-for-byte intact.
assert_eq!(fixture_query_rows(conn, "SELECT * FROM notification_sources WHERE kind='implementation_result' ORDER BY sequence"), evidence.0);
assert_eq!(fixture_query_rows(conn, "SELECT d.* FROM notification_decisions d JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.kind='implementation_result' ORDER BY s.sequence"), evidence.1);
Ok(())
}).unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
assert_eq!(reopened.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
assert_eq!(reopened.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
}
#[tokio::test]
async fn implementation_result_requires_live_binding_and_registered_worker_even_for_replay() {
for change in ["revoked", "binding-changed", "unregistered", "foreign-worker", "foreign-binding"] {
let (_dir, store) = tracking_store_fixture().await;
store.upsert_workspace(&WorkspaceRecord {
workspace_id: "other".into(), owner_account_id: "owner-account".into(),
display_name: "Other".into(), state: "active".into(),
created_at: "1".into(), updated_at: "1".into(),
}).await.unwrap();
let worker = RuntimeWorkerRef::new("r", "replacement");
let input = implementation_result_input();
store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
store.with_conn(|conn| {
match change {
"revoked" => { conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2' WHERE workspace_id='w'", [])?; }
"binding-changed" => { conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'", [])?; }
"unregistered" => { conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND worker_id='replacement'", [])?; }
"foreign-worker" => { conn.execute("UPDATE worker_registry SET workspace_id='other' WHERE workspace_id='w' AND worker_id='replacement'", [])?; }
"foreign-binding" => { conn.execute("UPDATE workspace_runtime_bindings SET workspace_id='other' WHERE workspace_id='w'", [])?; }
_ => unreachable!(),
}
Ok(())
}).unwrap();
let mut fresh = input.clone();
fresh.operation_key = "fresh-after-auth-change".into();
for request in [&input, &fresh] {
assert!(matches!(store.record_implementation_result("w", "tracking", request, &worker, "tracking-binding"),
Err(Error::WorkspacePermissionDenied(_))), "{change}: {request:?}");
}
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results", [], |r| r.get::<_, i64>(0))?, 1);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 1);
Ok(())
}).unwrap();
}
}
#[tokio::test]
async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_handling_state() {
for situation in ["never-assigned", "released", "reassigned", "closed", "no-orchestrator", "assigned-before-orchestrator"] {
let (_dir, store) = tracking_store_fixture().await;
if situation != "assigned-before-orchestrator" {
tracking_store_assign(&store, "delegation", "assign");
}
let worker = if situation == "never-assigned" {
RuntimeWorkerRef::new("r", "replacement")
} else {
routing_worker_assign(&store, "implementation").worker
};
match situation {
"never-assigned" => {}
"released" => {
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker,
"implementation", "release-event", "release-op", "human", "3", Some("Released")).unwrap());
}
"reassigned" => {
store.set_current_ticket_role_assignment(&manual_worker_assignment("tracking", "replacement-implementation"),
Some("implementation"), "reassign-event", "reassign-op", true).unwrap();
}
"closed" => {
store.with_conn(|conn| {
conn.execute("UPDATE typed_tickets SET workflow_state='closed' WHERE workspace_id='w' AND ticket_id='tracking'", [])?;
Ok(())
}).unwrap();
}
"no-orchestrator" => {
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator,
"delegation", "release-orchestrator-event", "release-orchestrator-op", "human", "3", Some("Returned to human")).unwrap());
}
"assigned-before-orchestrator" => { tracking_store_assign(&store, "late-delegation", "assign-late"); }
_ => unreachable!(),
}
let before = store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap();
let input = implementation_result_input();
let receipt = store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
store.with_conn(|conn| {
assert_eq!(implementation_result_business_state(conn), before, "{situation}");
let expected = match situation {
"no-orchestrator" => "",
"assigned-before-orchestrator" => "late-delegation",
_ => "delegation",
};
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, String>(0))?, expected);
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_delegations", [], |r| r.get::<_, i64>(0))?, 0, "new assignments must not create historical delegation evidence");
Ok(())
}).unwrap();
}
}
#[tokio::test]
async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
let (_dir, store) = tracking_store_fixture().await;
@@ -53,6 +237,11 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.is_err()
);
let mut changed_kind = input.clone();
changed_kind.kind = server_api::TicketImplementationResultKind::Blocker;
assert!(store
.record_implementation_result("w", "tracking", &changed_kind, &worker, "tracking-binding")
.is_err());
for (workspace, ticket, source, binding) in [
("other", "tracking", worker.clone(), "tracking-binding"),
("w", "other", worker.clone(), "tracking-binding"),
@@ -107,25 +296,37 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
.unwrap(),
receipt
);
changed.operation_key = "late-result".into();
assert!(
store
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.is_err()
);
changed.orchestrator_assignment_id = "replacement-delegation".into();
assert!(
store
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.is_err()
);
assert!(
!store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id.starts_with("implementation-result:"))
.any(|p| p.input_event_id == format!("implementation-result:{}", receipt.result_id))
);
changed.operation_key = "late-result".into();
let late = store
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.unwrap();
assert_ne!(late.result_id, receipt.result_id);
store.with_conn(|conn| {
for (result, assignment) in [
(&receipt.result_id, "delegation"),
(&late.result_id, "replacement-delegation"),
] {
assert_eq!(conn.query_row(
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1 AND role='orchestrator'",
[format!("implementation-result:{result}")], |r| r.get::<_, String>(0))?, assignment);
assert_eq!(conn.query_row(
"SELECT count(*) FROM notification_sources WHERE input_event_id=?1",
[format!("implementation-result:{result}")], |r| r.get::<_, i64>(0))?, 1);
}
Ok(())
}).unwrap();
assert!(store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.iter()
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id)));
}
#[tokio::test]
@@ -166,6 +367,137 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
.unwrap();
}
#[tokio::test]
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence() {
let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
// Reconstruct schema97 from a fresh schema98 fixture, including its historical capture.
store.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER notification_mr_event;
DROP TRIGGER notification_mr_source;
DROP TRIGGER notification_implementation_result;
DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
migrate_business_notifications_v96_to_v97(conn)?;
assert_eq!(current_schema_version(conn)?, 97);
Ok(())
}).unwrap();
let input = implementation_result_input();
let mut second = input.clone();
second.operation_key = "legacy-second-operation".into();
second.body = "A second retained report".into();
let requests = [input.clone(), second];
let raw_requests = requests.iter().map(|request| {
let mut legacy = serde_json::to_value(request).unwrap();
legacy["assignment_id"] = serde_json::json!("implementation");
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
}).collect::<Vec<_>>();
store.with_conn(|conn| {
for (index, request) in requests.iter().enumerate() {
conn.execute("INSERT INTO ticket_implementation_results(workspace_id,ticket_id,operation_key,result_id,assignment_id,orchestrator_assignment_id,runtime_id,worker_id,request_json,created_at)
VALUES('w','tracking',?1,?2,'implementation','delegation','r','replacement',?3,'4')",
params![request.operation_key, format!("legacy-result-{index}"), raw_requests[index]])?;
}
Ok(())
}).unwrap();
// Populate persisted request, target, ACK and failure evidence, not just source rows.
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
for index in 0..requests.len() {
let input_event = format!("implementation-result:legacy-result-{index}");
assert!(pending.iter().any(|p| p.input_event_id == input_event));
let reserved = store.reserve_ticket_assignment_delivery("w", "delegation", &input_event, &tracking_store_source()).unwrap();
store.prepare_ticket_assignment_delivery_content("w", &reserved.delivery_id, "Retained legacy report notification", &tracking_store_source()).unwrap();
store.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &tracking_store_source()).unwrap();
store.with_conn(|conn| {
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'legacy-failure','4')", [&reserved.delivery_id])?;
Ok(())
}).unwrap();
}
routing_ack_worker_pending(&store, &TicketWorkerNotificationTarget {
worker: worker.clone(),
authentication_binding_id: "tracking-binding".into(),
});
let (results, business, evidence) = store.with_conn(|conn| {
Ok((
fixture_query_rows(conn, "SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results ORDER BY rowid"),
implementation_result_business_state(conn),
implementation_result_notification_evidence(conn),
))
}).unwrap();
assert!(evidence.iter().all(|rows| !rows.is_empty()), "fixture must retain nonempty source, decision, request, target, ACK and failure evidence");
store.with_conn(|conn| {
// The migration must roll back its trigger drops and leave every byte replayable.
conn.execute_batch("CREATE TABLE ticket_implementation_results_v98(collision INTEGER);")?;
assert!(migrate_result_reports_v97_to_v98(conn).is_err());
assert_eq!(current_schema_version(conn)?, 97);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name IN ('notification_implementation_result','implementation_delegation_capture')", [], |r| r.get::<_, i64>(0))?, 2);
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 2);
assert_eq!(fixture_query_rows(conn, "SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results ORDER BY rowid"), results);
assert_eq!(implementation_result_business_state(conn), business);
assert_eq!(implementation_result_notification_evidence(conn), evidence);
conn.execute_batch("DROP TABLE ticket_implementation_results_v98;")?;
migrate_result_reports_v97_to_v98(conn)?;
assert_eq!(current_schema_version(conn)?, 98);
assert!(migrate_result_reports_v97_to_v98(conn).is_err(), "migration rejects the wrong starting version");
assert_eq!(fixture_query_rows(conn, "SELECT * FROM ticket_implementation_results ORDER BY rowid"), results);
assert_eq!(implementation_result_business_state(conn), business);
assert_eq!(implementation_result_notification_evidence(conn), evidence);
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0);
assert!(conn.execute("UPDATE ticket_implementation_results SET created_at='changed'", []).is_err());
assert!(conn.execute("DELETE FROM ticket_implementation_results", []).is_err());
assert!(conn.execute("UPDATE ticket_implementation_delegations SET orchestrator_assignment_id='delegation'", []).is_err());
assert!(conn.execute("DELETE FROM ticket_implementation_delegations", []).is_err());
assert!(conn.execute("INSERT INTO ticket_implementation_results SELECT workspace_id,'missing','missing-key','missing-result',runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results LIMIT 1", []).is_err(), "Ticket foreign key remains enforced");
assert!(conn.execute("INSERT INTO ticket_implementation_results SELECT workspace_id,ticket_id,'duplicate-result-key',result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results LIMIT 1", []).is_err(), "result identity remains unique");
assert!(conn.execute("INSERT INTO ticket_implementation_results SELECT workspace_id,ticket_id,operation_key,'duplicate-key-result',runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results LIMIT 1", []).is_err(), "operation key remains unique");
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r.get::<_, i64>(0))?, 0);
Ok(())
}).unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
for (index, request) in requests.iter().enumerate() {
let expected = server_api::TicketImplementationResultReceipt {
result_id: format!("legacy-result-{index}"), ticket_id: "tracking".into(),
request: request.clone(), created_at: "4".into(),
};
assert_eq!(reopened.implementation_result("w", "tracking", &expected.result_id).unwrap(), expected);
assert_eq!(reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap(), expected);
}
reopened.with_conn(|conn| {
assert_eq!(fixture_query_rows(conn, "SELECT * FROM ticket_implementation_results ORDER BY rowid"), results);
assert_eq!(implementation_result_notification_evidence(conn), evidence, "reading and replaying legacy JSON must never recapture notifications");
Ok(())
}).unwrap();
tracking_store_assign(&reopened, "new-delegation", "assign-new");
routing_worker_assign(&reopened, "new-implementation");
reopened.with_conn(|conn| {
assert_eq!(stored_fixture_rows(conn, "ticket_implementation_delegations"), business[9], "historical delegation evidence must not grow");
for index in 0..requests.len() {
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, String>(0))?, "delegation");
}
Ok(())
}).unwrap();
for request in &requests {
reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap();
}
let mut fresh = input;
fresh.operation_key = "new-schema98-report".into();
let receipt = reopened.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding").unwrap();
reopened.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:{}", receipt.result_id)], |r| r.get::<_, String>(0))?, "new-delegation");
for index in 0..requests.len() {
assert_eq!(conn.query_row("SELECT request_json FROM ticket_implementation_results WHERE result_id=?1", [format!("legacy-result-{index}")], |r| r.get::<_, String>(0))?, raw_requests[index]);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, i64>(0))?, 1);
}
Ok(())
}).unwrap();
}
#[tokio::test]
async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknown_legacy_results() {
let (_dir, store) = tracking_store_fixture().await;
@@ -175,9 +507,9 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[serde_json::to_string(&tracking_store_source()).unwrap()])?;
// Reconstruct the prior composition before its new business capture existed.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER implementation_delegation_capture;
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=97;")?;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
INSERT INTO merge_request_thread_events VALUES('w','historical-mr','approved-event',1,'review','{\"subject_ref\":\"source-a\",\"decision\":\"approve\"}',NULL,'3');
@@ -233,6 +565,9 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
let input = implementation_result_input();
let before = store
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
.unwrap();
store.with_conn(|conn| {
conn.execute_batch("CREATE TRIGGER reject_result_notification BEFORE INSERT ON notification_sources WHEN NEW.kind='implementation_result' BEGIN SELECT RAISE(ABORT,'injected capture failure'); END;")?;
Ok(())
@@ -252,6 +587,8 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
)?,
0
);
assert_eq!(implementation_result_business_state(conn), before);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 0);
conn.execute_batch("DROP TRIGGER reject_result_notification;")?;
Ok(())
})
@@ -324,9 +661,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
];
store.with_conn(|conn| {
if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER implementation_delegation_capture;
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=97;")?;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
}
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
+2
View File
@@ -26,6 +26,8 @@ pub mod server;
pub mod skills;
pub mod store;
pub mod subjektiv;
#[cfg(test)]
mod ticket_browse_performance_tests;
mod ticket_item_checker;
pub mod workdir_create_operations;
mod workdir_removal;
@@ -375,6 +375,23 @@ pub(super) fn install_business_events(conn: &Connection) -> Result<()> {
Ok(())
}
/// New reports notify the delegation present when the report is saved. Assignment
/// identity only fences this saved notification against later retargeting; it is
/// neither a caller input nor permission to publish a report.
pub(super) fn upgrade_result_reports(conn: &Connection) -> Result<()> {
conn.execute_batch(include_str!("implementation_result_reports.sql"))?;
conn.execute_batch(&format!("CREATE TRIGGER notification_implementation_result AFTER INSERT ON ticket_implementation_results BEGIN
INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,actor,event_index,facts_json)
SELECT NEW.workspace_id,NEW.ticket_id,'implementation-result:' || NEW.result_id,'orchestrator',COALESCE(a.assignment_id,''),'implementation_result','worker:' || NEW.runtime_id || '/' || NEW.worker_id,NULL,
json_set({FACTS_SQL},'$.result_id',NEW.result_id,'$.merge_request_id',json_extract(NEW.request_json,'$.merge_request.merge_request_id'))
FROM typed_tickets t
LEFT JOIN ticket_current_worker_assignments c ON c.workspace_id=t.workspace_id AND c.ticket_id=t.ticket_id AND c.role='orchestrator'
LEFT JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id
WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id;
END;"))?;
Ok(())
}
impl SqliteWorkspaceStore {
/// Record a provider-resolved source observation, not approval. Git has no local
/// transactional event log: transitions are captured when the existing MR paths
@@ -438,12 +455,14 @@ pub(super) fn verify(conn: &Connection) -> Result<()> {
] {
names.push(name.into());
}
if current_schema_version(conn)? >= 96 {
if current_schema_version(conn)? == 97 {
names.push("implementation_delegation_capture".into());
}
if current_schema_version(conn)? >= 97 {
names.extend(
[
"ticket_implementation_delegations",
"ticket_implementation_results",
"implementation_delegation_capture",
"implementation_results_no_update",
"implementation_results_no_delete",
"implementation_delegations_no_update",
@@ -845,6 +845,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
migrate_notification_routing_v94_to_v95(&conn).unwrap();
migrate_job_notifications_v95_to_v96(&conn).unwrap();
migrate_business_notifications_v96_to_v97(&conn).unwrap();
migrate_result_reports_v97_to_v98(&conn).unwrap();
let snapshot = |conn: &Connection| -> Vec<(String, String)> {
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' OR name LIKE 'ticket_assignment_content%' OR name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name").unwrap().query_map([],|r|Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap()
};
+24
View File
@@ -6671,6 +6671,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::ticket_create_record(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_browse(service.clone()))
.merge(server_api::server_api_axum::ticket_query(service.clone()))
.merge(server_api::server_api_axum::ticket_get_alias(
service.clone(),
@@ -10148,6 +10149,25 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(server_api::TicketRecordRef(response))
}
async fn ticket_browse(
&self,
workspace_id: String,
request: server_api::TicketBrowseQueryRequest,
) -> std::result::Result<server_api::TicketBrowseQueryResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?.clone();
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
api.authority
.query_ticket_browse(request)
.map_err(ApiError::from)
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn ticket_query(
&self,
workspace_id: String,
@@ -35868,6 +35888,10 @@ mod tests {
include!("server_ticket_tags_tests.rs");
}
mod ticket_browse_tests {
include!("server_ticket_browse_tests.rs");
}
mod api_logging_tests;
mod auth_logging_tests;
mod drive_api_tests;
@@ -18,6 +18,48 @@ const EVIDENCE_MR: &str = "ticket-evidence-mr";
const EVIDENCE_SOURCE: &str = "immutable-approved-source";
const EVIDENCE_TARGET: &str = "immutable-target-result";
#[tokio::test]
async fn ticket_browse_does_not_observe_sources_but_authoritative_query_and_show_still_do() {
struct CountingObserver(Arc<std::sync::atomic::AtomicUsize>);
impl crate::authority::TicketSourceCommitResolver for CountingObserver {
fn resolve_source_commit(
&self,
_ticket: &str,
_repository: &str,
_selector: &str,
) -> std::result::Result<String, MergeRequestRefDiagnostic> {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(EVIDENCE_SOURCE.into())
}
}
let mut fixture = EvidenceApiFixture::new().await;
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
fixture.api.authority = fixture
.api
.authority
.clone()
.with_source_commit_resolver(Arc::new(CountingObserver(calls.clone())));
let response = request_json(
build_inner_router(fixture.api.clone()),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/browse"),
Some(json!({"limit":30})),
StatusCode::OK,
)
.await;
assert_eq!(response["items"][0]["id"], fixture.ticket_id);
assert!(response["items"][0].get("evidence").is_none());
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
fixture
.api
.authority
.query_tickets(Default::default())
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
fixture.api.authority.ticket(&fixture.ticket_id).unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
}
// Deliberately freeze review dates: freshness must follow the exact item
// content digest and source/result snapshot, not a comparison of wall-clock dates.
fn evidence_time() -> chrono::DateTime<Utc> {
@@ -1673,7 +1673,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() {
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/implementation-results",
ticket.id
);
let mut result = json!({"operation_key":"observed-result","assignment_id":"opening-coder","orchestrator_assignment_id":format!("orchestrator-{}",ticket.id),"kind":"handoff","body":"Published new source; review not requested yet.","merge_request":{"merge_request_id":mr,"subject_ref":"forged-source"}});
let mut result = json!({"operation_key":"observed-result","kind":"handoff","body":"Published new source; review not requested yet.","merge_request":{"merge_request_id":mr,"subject_ref":"forged-source"}});
call(
"POST",
result_path.clone(),
@@ -43,7 +43,7 @@ async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorker
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn implementation_result_signed_http_reaches_persistent_orchestrator_once_without_mr() {
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
@@ -66,22 +66,14 @@ async fn implementation_result_signed_http_reaches_persistent_orchestrator_once_
.await;
let coder = spawn_ticket_check_source(&api, "research-coder");
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
delivery_assign_worker(&api, &ticket.id, &coder, "research-implementation", None);
// Reporting does not require an implementation assignment.
recover_ticket_notifications(&api);
let before = delivery_tracking(&api, &ticket.id, delegation);
let input = json!({"operation_key":"research-result","assignment_id":"research-implementation","orchestrator_assignment_id":delegation,"kind":"result","body":"Documented findings; no repository or MR was required.","merge_request":null});
// The registered Orchestrator is not the implementation assignee and cannot forge this result.
result_signed_http(
&api,
&identity,
&orchestrator,
&ticket.id,
&input,
StatusCode::FORBIDDEN,
)
.await;
let input = json!({"operation_key":"research-result","kind":"result","body":"Documented findings; no repository or MR was required.","merge_request":null});
let receipt =
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
// Another authenticated Worker may report, but cannot replay this Workers receipt.
result_signed_http(&api, &identity, &orchestrator, &ticket.id, &input, StatusCode::CONFLICT).await;
delivery_wait_idle(&api, &orchestrator, 2).await;
assert_eq!(
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await,
@@ -239,7 +231,7 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
assert_eq!(pending["source_checks"][0]["captured"], true);
recover_ticket_notifications(&api);
delivery_wait_idle(&api, &orchestrator, 3).await;
let input = json!({"operation_key":"approved-handoff","assignment_id":"mr-implementation","orchestrator_assignment_id":delegation,"kind":"handoff","body":"Implementation and validation complete; integration remains separate.","merge_request":{"merge_request_id":"result-mr","subject_ref":"source-a"}});
let input = json!({"operation_key":"approved-handoff","kind":"handoff","body":"Implementation and validation complete; integration remains separate.","merge_request":{"merge_request_id":"result-mr","subject_ref":"source-a"}});
let receipt =
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
delivery_wait_idle(&api, &orchestrator, 4).await;
@@ -0,0 +1,72 @@
use super::*;
#[tokio::test]
async fn ticket_browse_http_projects_only_list_fields_and_rejects_evidence_queries() {
let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let created = backend
.create(ticket::NewTicket::new("Lightweight browse HTTP"))
.unwrap();
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/browse");
request_json(
build_router(api.clone()),
"POST",
&path,
Some(json!({})),
StatusCode::UNAUTHORIZED,
)
.await;
let app = build_inner_router(api);
let response = request_json(
app.clone(),
"POST",
&path,
Some(json!({"states":["open"],"query":"Lightweight browse HTTP","limit":30})),
StatusCode::OK,
)
.await;
let parsed: server_api::TicketBrowseQueryResponse =
serde_json::from_value(response.clone()).unwrap();
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0].id, created.id);
assert_eq!(parsed.record_authority, "workspace-sqlite");
for absent in [
"evidence",
"merge_requests",
"body",
"events",
"content_digest",
] {
assert!(response["items"][0].get(absent).is_none(), "{absent}");
}
// Browse cannot silently weaken an evidence query into a metadata filter.
request_json(
app.clone(),
"POST",
&path,
Some(json!({"review_status":"approved"})),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
request_json(
app.clone(),
"POST",
&path,
Some(json!({"sort":"relevance"})),
StatusCode::BAD_REQUEST,
)
.await;
let foreign = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/w/other-workspace/tickets/browse")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert!(!foreign.status().is_success());
}
+37 -2
View File
@@ -84,7 +84,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 97;
const LATEST_SCHEMA_VERSION: i64 = 98;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -377,6 +377,11 @@ const MIGRATIONS: &[Migration] = &[
name: "MR notifications and authenticated implementation results",
apply: migrate_business_notifications_v96_to_v97,
},
Migration {
version: 98,
name: "Assignment-independent implementation reports",
apply: migrate_result_reports_v97_to_v98,
},
];
#[derive(Clone, Copy)]
@@ -14286,6 +14291,32 @@ fn migrate_business_notifications_v96_to_v97(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 97 {
return Err(Error::Store(
"Result reports migration requires schema 97".into(),
));
}
let tx = conn.unchecked_transaction()?;
notification_routing::upgrade_result_reports(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(98,?1)",
["Assignment-independent implementation reports"],
)?;
verify_workspace_resource_constraints(&tx)?;
let violations: i64 =
tx.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| {
r.get(0)
})?;
if violations != 0 {
return Err(Error::Store(
"Result reports migration foreign-key violation".into(),
));
}
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
@@ -14293,6 +14324,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
notification_routing::install(conn)?;
notification_routing::jobs::install(conn)?;
notification_routing::install_business_events(conn)?;
notification_routing::upgrade_result_reports(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -17527,6 +17559,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:95, name:"Common notification routing and durable Worker delivery".into()},
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
]
);
@@ -17634,6 +17667,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(95, "Common notification routing and durable Worker delivery".to_string()),
(96, "Backend Job common notification routing".to_string()),
(97, "MR notifications and authenticated implementation results".to_string()),
(98, "Assignment-independent implementation reports".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -20255,7 +20289,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(94, "Canonical Ticket posts".into()),
(95, "Common notification routing and durable Worker delivery".into()),
(96, "Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into())]);
(97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -0,0 +1,375 @@
//! SQL-only browser projection. No Ticket detail, dependency graph, MR/provider
//! observations, or per-ticket reads. Full QueryTicket/show retain their own semantics.
use super::{Error, RECORD_SOURCE_WORKSPACE_SQLITE, Result, SqliteWorkspaceAuthority};
use rusqlite::{Connection, params_from_iter, types::Value};
use server_api::{
BrowserTicketState, QueryPage, TicketBrowseQueryItem, TicketBrowseQueryRequest,
TicketBrowseQueryResponse,
};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
#[cfg(test)]
#[path = "ticket_browse_tests.rs"]
mod tests;
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowseCursor {
scope: String,
updated_at: Option<String>,
id: String,
}
impl SqliteWorkspaceAuthority {
/// A filtered page of display fields in one SQLite read snapshot. All matching
/// predicates execute before LIMIT; unrelated live evidence is never observed.
pub fn query_ticket_browse(
&self,
mut query: TicketBrowseQueryRequest,
) -> Result<TicketBrowseQueryResponse> {
if query
.sort
.as_deref()
.is_some_and(|sort| sort != "updated_desc")
{
return Err(Error::InvalidInput(
"Ticket browse supports only updated_desc".into(),
));
}
query.sort = Some("updated_desc".into());
query.states.sort_by_key(|state| state.as_str());
query.states.dedup();
query.tag_ids.sort();
query.tag_ids.dedup();
if query.tag_ids.len() > 100 || query.tag_ids.iter().any(|id| id.trim().is_empty()) {
return Err(Error::InvalidInput(
"Ticket browse requires at most 100 nonblank tag IDs".into(),
));
}
query.query = query
.query
.filter(|text| !text.is_empty())
.map(|text| text.to_lowercase());
let cursor = query.cursor.take();
let limit = query.limit.take().unwrap_or(50).clamp(1, 100);
let digest = Sha256::digest(
serde_json::to_vec(&(&self.workspace_id, &query))
.map_err(|e| Error::InvalidInput(e.to_string()))?,
);
let scope = format!(
"ticket-browse-v1:{}",
digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
let after = cursor
.map(|cursor| -> Result<BrowseCursor> {
let cursor: BrowseCursor = serde_json::from_str(&cursor)
.map_err(|_| Error::InvalidInput("Invalid Ticket browse cursor".into()))?;
if cursor.scope != scope || cursor.id.is_empty() {
return Err(Error::InvalidInput(
"Ticket browse cursor does not match Workspace, filters or sort".into(),
));
}
Ok(cursor)
})
.transpose()?;
let (sql, values) = page_sql(&self.workspace_id, &query, after.as_ref(), limit);
self.store.with_conn_mut(|conn| {
if query.query.is_some() {
// SQLite lower() only folds ASCII. Use the same Unicode lowercasing
// as the bound needle, without post-filtering a limited page.
conn.create_scalar_function(
"ticket_browse_contains",
2,
rusqlite::functions::FunctionFlags::SQLITE_UTF8
| rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC
| rusqlite::functions::FunctionFlags::SQLITE_INNOCUOUS,
|context| {
let raw_text = context.get_raw(0);
if raw_text == rusqlite::types::ValueRef::Null {
return Ok(false);
}
let text = raw_text
.as_str()
.map_err(|error| rusqlite::Error::UserFunctionError(Box::new(error)))?;
let needle = context
.get_raw(1)
.as_str()
.map_err(|error| rusqlite::Error::UserFunctionError(Box::new(error)))?;
Ok(text.to_lowercase().contains(needle))
},
)?;
}
let tx = conn.transaction()?;
let mut items = {
let mut statement = tx.prepare(&sql)?;
let rows = statement.query_map(params_from_iter(values.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
))
})?;
rows.map(|row| {
let (id, key, title, state, updated_at) = row?;
let resource_key =
key.ok_or_else(|| Error::Store(format!("missing resource key for {id}")))?;
let state = match state.as_str() {
"open" => BrowserTicketState::Open,
"closed" => BrowserTicketState::Closed,
_ => {
return Err(Error::Store(format!("invalid Ticket case state {state}")));
}
};
Ok(TicketBrowseQueryItem {
id,
resource_key,
title,
state,
updated_at,
tags: vec![],
current_closure: None,
assignment_tracking: vec![],
})
})
.collect::<Result<Vec<_>>>()?
};
let has_more = items.len() > limit;
items.truncate(limit);
enrich_page(&tx, &self.workspace_id, &mut items)?;
let next_cursor = if has_more {
items
.last()
.map(|item| {
serde_json::to_string(&BrowseCursor {
scope,
updated_at: item.updated_at.clone(),
id: item.id.clone(),
})
.map_err(|e| Error::Store(e.to_string()))
})
.transpose()?
} else {
None
};
tx.commit()?;
Ok(TicketBrowseQueryResponse {
page: QueryPage {
limit,
returned: items.len(),
has_more,
next_cursor,
sort: "updated_desc".into(),
source_limit: None,
source_truncated: false,
},
items,
record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.into(),
})
})
}
}
fn page_sql(
workspace: &str,
query: &TicketBrowseQueryRequest,
after: Option<&BrowseCursor>,
limit: usize,
) -> (String, Vec<Value>) {
let mut values = vec![Value::Text(workspace.into())];
let mut predicates = vec!["t.workspace_id=?1".to_string()];
let mut bind = |value: String| {
values.push(Value::Text(value));
format!("?{}", values.len())
};
if !query.states.is_empty() {
let states = query
.states
.iter()
.map(|state| bind(state.as_str().into()))
.collect::<Vec<_>>();
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
}
if let Some(reason) = &query.closure_reason {
let reason = bind(serde_json::to_string(reason).expect("closure reason serializes"));
predicates.push(format!("t.workflow_state='closed' AND EXISTS (
SELECT 1 FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h
ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key
WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id
AND json_extract(h.judgment_json,'$.action.reason')=json_extract({reason},'$'))"));
}
if query.assigned_principal.is_some() || query.handling_status.is_some() {
let mut terms = vec![
"c.workspace_id=t.workspace_id".to_string(),
"c.ticket_id=t.ticket_id".into(),
];
if let Some(principal) = &query.assigned_principal {
use ticket::TicketAssignmentPrincipalSnapshot::*;
terms.push(match principal {
WorkspaceAgent { agent_key } => format!(
"a.principal_kind='workspace_agent' AND a.principal_id={}",
bind(agent_key.clone())
),
User { account_id } => format!(
"a.principal_kind='user' AND a.principal_id={}",
bind(account_id.clone())
),
Worker {
runtime_id,
worker_id,
} => format!(
"a.principal_kind='worker' AND a.runtime_id={} AND a.worker_id={}",
bind(runtime_id.clone()),
bind(worker_id.clone())
),
});
}
if let Some(status) = &query.handling_status {
let status = match status {
ticket::TicketAssignmentTrackingStatus::Waiting => "waiting",
ticket::TicketAssignmentTrackingStatus::Inprogress => "inprogress",
ticket::TicketAssignmentTrackingStatus::Done => "done",
};
terms.push(format!(
"a.role='orchestrator' AND json_extract(a.tracking_json,'$.status')={}",
bind(status.into())
));
}
predicates.push(format!("EXISTS (SELECT 1 FROM ticket_current_worker_assignments c
JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id AND a.role=c.role
WHERE {})", terms.join(" AND ")));
}
for tag in &query.tag_ids {
let tag = bind(tag.clone());
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l
JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id AND tag.deleted=0
WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={tag})"));
}
if let Some(text) = &query.query {
// Literal substring search: %, _ and backslash are not wildcards.
// Initial comment (-1) is the authoritative body.
let needle = bind(text.clone());
predicates.push(format!("(ticket_browse_contains(t.title,{needle}) OR EXISTS (
SELECT 1 FROM typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
AND e.archived=0 AND e.event_index>=-1 AND ticket_browse_contains(e.body,{needle}))
OR EXISTS (SELECT 1 FROM typed_ticket_case_history h
WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id
AND ticket_browse_contains(CASE json_type(h.judgment_json,'$.action.reason')
WHEN 'object' THEN 'other ' || json_extract(h.judgment_json,'$.action.reason.other')
ELSE json_extract(h.judgment_json,'$.action.reason') END,{needle})))"));
}
if let Some(after) = after {
let id = bind(after.id.clone());
if let Some(time) = &after.updated_at {
let time = bind(time.clone());
predicates.push(format!("(t.updated_at<{time} OR t.updated_at IS NULL OR (t.updated_at={time} AND t.ticket_id>{id}))"));
} else {
predicates.push(format!("(t.updated_at IS NULL AND t.ticket_id>{id})"));
}
}
values.push(Value::Integer((limit + 1) as i64));
let sql = format!("SELECT t.ticket_id,k.resource_key,t.title,t.workflow_state,t.updated_at
FROM typed_tickets t LEFT JOIN workspace_resource_keys k
ON k.workspace_id=t.workspace_id AND k.resource_kind='ticket' AND k.resource_id=t.ticket_id
WHERE {} ORDER BY t.updated_at DESC,t.ticket_id ASC LIMIT ?{}", predicates.join(" AND "), values.len());
(sql, values)
}
fn enrich_page(
conn: &Connection,
workspace: &str,
items: &mut [TicketBrowseQueryItem],
) -> Result<()> {
if items.is_empty() {
return Ok(());
}
let positions: HashMap<String, usize> = items
.iter()
.enumerate()
.map(|(i, item)| (item.id.clone(), i))
.collect();
let mut values = vec![Value::Text(workspace.into())];
values.extend(items.iter().map(|item| Value::Text(item.id.clone())));
let ids = (2..=values.len())
.map(|i| format!("?{i}"))
.collect::<Vec<_>>()
.join(",");
let mut statement = conn.prepare(&format!("SELECT l.ticket_id,tag.tag_id,tag.name,tag.last_operation_key
FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id
WHERE l.workspace_id=?1 AND l.ticket_id IN ({ids}) AND tag.deleted=0 ORDER BY l.ticket_id,tag.tag_id"))?;
let mut rows = statement.query(params_from_iter(values.iter()))?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
items[positions[&id]].tags.push(ticket::WorkspaceTag {
workspace_id: workspace.into(),
id: row.get(1)?,
name: row.get(2)?,
last_operation_key: row.get(3)?,
deleted: false,
});
}
let mut statement = conn.prepare(&format!("SELECT t.ticket_id,h.judgment_json FROM typed_tickets t
JOIN typed_ticket_case_metadata m ON m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id
JOIN typed_ticket_case_history h ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key
WHERE t.workspace_id=?1 AND t.ticket_id IN ({ids}) AND t.workflow_state='closed'"))?;
let mut rows = statement.query(params_from_iter(values.iter()))?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
let entry: ticket::TicketCaseHistoryEntry = decode_json(&row.get::<_, String>(1)?)?;
let reason = match entry.action {
ticket::TicketCaseAction::Close { reason }
| ticket::TicketCaseAction::CorrectClosure { reason } => reason,
ticket::TicketCaseAction::Reopen => {
return Err(Error::Store("Closure key names a reopen receipt".into()));
}
};
items[positions[&id]].current_closure = Some(ticket::TicketClosureJudgment {
operation_key: entry.operation_key,
reason,
explanation: entry.explanation,
references: entry.references,
author: entry.author,
at: entry.at,
});
}
// Current Orchestrator projections only, exactly as TicketQueryItem. Legacy
// unknown handling retains its real Assign observation without inventing status.
let mut statement = conn.prepare(&format!("SELECT a.ticket_id,json_object(
'workspace_id',a.workspace_id,'ticket_id',a.ticket_id,'assignment_id',a.assignment_id,'role',a.role,
'principal',json(CASE a.principal_kind
WHEN 'workspace_agent' THEN json_object('kind','workspace_agent','agent_key',a.principal_id)
WHEN 'user' THEN json_object('kind','user','account_id',a.principal_id)
WHEN 'worker' THEN json_object('kind','worker','runtime_id',a.runtime_id,'worker_id',a.worker_id) END),
'assigned_by',a.assigned_by,'assigned_at',a.assigned_at,'release',NULL,'current',json('true'),
'tracking',json(a.tracking_json),'last_event_id',CASE
WHEN a.tracking_json IS NULL AND a.last_tracking_event_id IS NULL THEN (
SELECT e.event_id FROM ticket_worker_assignment_events e
WHERE e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.assignment_id=a.assignment_id
AND e.role='orchestrator' AND e.action IN ('assigned','reassigned')
AND NOT EXISTS (SELECT 1 FROM typed_ticket_assignment_tracking_history h
WHERE h.workspace_id=a.workspace_id AND h.ticket_id=a.ticket_id AND h.assignment_id=a.assignment_id)
ORDER BY e.rowid LIMIT 1)
ELSE a.last_tracking_event_id END,
'updated_at',a.tracking_updated_at,'migration_diagnostic',a.tracking_migration_diagnostic)
FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a
ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id AND a.role=c.role
WHERE c.workspace_id=?1 AND c.ticket_id IN ({ids}) AND c.role='orchestrator'
ORDER BY a.ticket_id,a.assignment_id"))?;
let mut rows = statement.query(params_from_iter(values.iter()))?;
while let Some(row) = rows.next()? {
let id: String = row.get(0)?;
items[positions[&id]]
.assignment_tracking
.push(decode_json(&row.get::<_, String>(1)?)?);
}
Ok(())
}
fn decode_json<T: serde::de::DeserializeOwned>(json: &str) -> Result<T> {
serde_json::from_str(json).map_err(|e| Error::Store(e.to_string()))
}
@@ -0,0 +1,173 @@
//! Reproducible component benchmark: disposable SQLite, real authority methods, no HTTP/Runtime.
//! Run with `cargo test -p yoi-workspace-server ticket_browse_component_performance --lib -- --ignored --nocapture`.
use crate::authority::{SqliteWorkspaceAuthority, TicketAuthority};
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use rusqlite::{Connection, params};
use std::time::Instant;
use ticket::{SqliteTicketBackend, TicketBackend};
async fn fixture(
tickets: usize,
events_per_ticket: usize,
) -> (tempfile::TempDir, SqliteWorkspaceAuthority) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("benchmark.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "bench".into(),
owner_account_id: "owner-account".into(),
display_name: "Component benchmark".into(),
state: "active".into(),
created_at: "2026-10-01T00:00:00Z".into(),
updated_at: "2026-10-01T00:00:00Z".into(),
})
.await
.unwrap();
let backend = SqliteTicketBackend::open_verified(&path, "bench").unwrap();
let seed = backend
.create(ticket::NewTicket::new("Benchmark seed"))
.unwrap();
let mut conn = Connection::open(&path).unwrap();
conn.pragma_update(None, "foreign_keys", true).unwrap();
let ticket_columns: Vec<String> = conn
.prepare("PRAGMA table_info(typed_tickets)")
.unwrap()
.query_map([], |row| row.get(1))
.unwrap()
.map(Result::unwrap)
.collect();
let expressions = ticket_columns
.iter()
.map(|name| match name.as_str() {
"ticket_id" | "slug" => "?1".to_owned(),
"title" => "?2".to_owned(),
"created_at" | "updated_at" => "'2026-10-01T00:00:00Z'".to_owned(),
_ => format!("\"{name}\""),
})
.collect::<Vec<_>>()
.join(",");
let clone_ticket = format!(
"INSERT INTO typed_tickets SELECT {expressions} FROM typed_tickets WHERE workspace_id='bench' AND ticket_id=?3"
);
let tx = conn.transaction().unwrap();
{
let mut insert_ticket = tx.prepare(&clone_ticket).unwrap();
let mut insert_key = tx.prepare("INSERT INTO workspace_resource_keys(workspace_id,resource_kind,resource_id,sequence,resource_key,allocated_at) VALUES('bench','ticket',?1,?2,?3,'2026-10-01T00:00:00Z')").unwrap();
let mut insert_event = tx.prepare("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref,archived) VALUES('bench',?1,?2,'comment',?3,?4,0)").unwrap();
let mut insert_attribute = tx.prepare("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES('bench',?1,?2,'bench','value')").unwrap();
let mut insert_reference = tx.prepare("INSERT INTO typed_ticket_event_references(workspace_id,ticket_id,event_index,ordinal,kind,target) VALUES('bench',?1,?2,0,'ticket',?3)").unwrap();
let mut insert_relation = tx.prepare("INSERT INTO typed_ticket_relations(workspace_id,ticket_id,kind,target,author,at) VALUES('bench',?1,'related',?2,'owner-account','2026-10-01T00:00:00Z')").unwrap();
let body = "component benchmark text ".repeat(40);
for i in 0..tickets {
let id = format!("bench-{i:06}");
insert_ticket
.execute(params![id, format!("Ticket {i:06}"), seed.id])
.unwrap();
insert_key
.execute(params![id, i + 2, format!("T-{}", i + 2)])
.unwrap();
insert_relation.execute(params![id, seed.id]).unwrap();
for e in -1..events_per_ticket as i64 {
let text = if e == events_per_ticket as i64 - 1 {
format!("search-needle {body}")
} else {
body.clone()
};
insert_event
.execute(params![id, e, text, format!("{id}-event-{e}")])
.unwrap();
insert_attribute.execute(params![id, e]).unwrap();
insert_reference.execute(params![id, e, seed.id]).unwrap();
}
}
}
tx.commit().unwrap();
drop(conn);
let authority = SqliteWorkspaceAuthority::new(path, "bench").unwrap();
(dir, authority)
}
fn query(text: Option<&str>) -> server_api::TicketQueryRequest {
server_api::TicketQueryRequest {
states: vec![ticket::TicketState::Open],
sort: Some("updated_desc".into()),
limit: Some(30),
query: text.map(str::to_owned),
..Default::default()
}
}
#[tokio::test]
#[ignore = "component timing benchmark; no production services"]
async fn ticket_browse_component_performance() {
let tickets = std::env::var("BROWSE_BENCH_TICKETS")
.ok()
.map(|v| v.parse().unwrap())
.unwrap_or(1_000);
let events = std::env::var("BROWSE_BENCH_EVENTS")
.ok()
.map(|v| v.parse().unwrap())
.unwrap_or(100);
let (_dir, authority) = fixture(tickets, events).await;
for text in [
None,
Some("search-needle"),
Some("Ticket 00099"),
Some("absent-needle"),
] {
let request = query(text);
let browse = server_api::TicketBrowseQueryRequest {
states: request.states.clone(),
sort: request.sort.clone(),
limit: request.limit,
query: request.query.clone(),
..Default::default()
};
// Warm both paths on the same database; exclude fixture generation.
authority.query_tickets(request.clone()).unwrap();
authority.query_ticket_browse(browse.clone()).unwrap();
let mut old_times = Vec::new();
let mut new_times = Vec::new();
let mut old_bytes = 0;
let mut new_bytes = 0;
// Alternate paths to reduce bias from unrelated machine activity. Include
// complete response JSON serialization, but not parity assertions, in timings.
for _ in 0..3 {
let started = Instant::now();
let old = authority.query_tickets(request.clone()).unwrap();
old_bytes = serde_json::to_vec(&old).unwrap().len();
old_times.push(started.elapsed().as_secs_f64() * 1000.0);
let started = Instant::now();
let new = authority.query_ticket_browse(browse.clone()).unwrap();
new_bytes = serde_json::to_vec(&new).unwrap().len();
new_times.push(started.elapsed().as_secs_f64() * 1000.0);
let projected = old
.items
.into_iter()
.map(|item| server_api::TicketBrowseQueryItem {
id: item.id,
resource_key: item.resource_key,
title: item.title,
state: item.state,
updated_at: item.updated_at,
tags: item.tags,
current_closure: item.current_closure,
assignment_tracking: item.assignment_tracking,
})
.collect::<Vec<_>>();
assert_eq!(projected, new.items);
assert!(new.items.len() <= 30);
if text == Some("absent-needle") {
assert!(new.items.is_empty());
}
assert_eq!(old.page.has_more, new.page.has_more);
}
old_times.sort_by(f64::total_cmp);
new_times.sort_by(f64::total_cmp);
println!(
"COMPARE tickets={tickets} events_each={events} search={text:?} samples=3 old_ms={old_times:?} new_ms={new_times:?} median_speedup={:.1} old_bytes={old_bytes} new_bytes={new_bytes}",
old_times[1] / new_times[1]
);
}
}
@@ -0,0 +1,620 @@
use super::*;
use crate::authority::TicketAuthority;
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use rusqlite::params;
use ticket::{SqliteTicketBackend, TicketBackend, TicketClosureReason, TicketState};
struct Fixture {
_dir: tempfile::TempDir,
authority: SqliteWorkspaceAuthority,
backend: SqliteTicketBackend,
}
impl Fixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("browse.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
for workspace in ["w", "foreign"] {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: workspace.into(),
owner_account_id: "owner-account".into(),
display_name: workspace.into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
}
Self {
authority: SqliteWorkspaceAuthority::new(&path, "w").unwrap(),
backend: SqliteTicketBackend::open_verified(&path, "w").unwrap(),
_dir: dir,
}
}
fn ticket(&self, title: &str, time: Option<&str>) -> String {
let id = self
.backend
.create(ticket::NewTicket::new(title))
.unwrap()
.id;
self.authority.store.with_conn(|conn| {
conn.execute("UPDATE typed_tickets SET updated_at=?1 WHERE workspace_id='w' AND ticket_id=?2", params![time,id])?;
Ok(())
}).unwrap();
id
}
fn run(&self, query: TicketBrowseQueryRequest) -> TicketBrowseQueryResponse {
self.authority.query_ticket_browse(query).unwrap()
}
fn tag(&self, ticket: &str, tag: &str) {
self.authority.store.with_conn(|conn| {
conn.execute("INSERT OR IGNORE INTO workspace_tags(workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES('w',?1,?1,?1,?1,0)",[tag])?;
conn.execute("INSERT INTO typed_ticket_tags(workspace_id,ticket_id,tag_id) VALUES('w',?1,?2)",params![ticket,tag])?;
Ok(())
}).unwrap();
}
fn closure(&self, id: &str, operation: &str, reason: TicketClosureReason, closed: bool) {
let entry = ticket::TicketCaseHistoryEntry {
operation_key: operation.into(),
previous_closure_operation_key: None,
action: ticket::TicketCaseAction::Close { reason },
explanation: "test judgment".into(),
references: vec![],
author: "owner-account".into(),
at: "1".into(),
};
self.authority.store.with_conn(|conn| {
conn.execute("INSERT INTO typed_ticket_case_history(workspace_id,ticket_id,ordinal,operation_key,request_fingerprint,judgment_json)
SELECT 'w',?1,coalesce(max(ordinal),0)+1,?2,'fixture',?3 FROM typed_ticket_case_history WHERE workspace_id='w' AND ticket_id=?1",
params![id,operation,serde_json::to_string(&entry).unwrap()])?;
conn.execute("INSERT INTO typed_ticket_case_metadata(workspace_id,ticket_id,closure_operation_key) VALUES('w',?1,?2)
ON CONFLICT(workspace_id,ticket_id) DO UPDATE SET closure_operation_key=excluded.closure_operation_key",params![id,operation])?;
conn.execute("UPDATE typed_tickets SET workflow_state=?1 WHERE workspace_id='w' AND ticket_id=?2",params![if closed {"closed"} else {"open"},id])?;
Ok(())
}).unwrap();
}
fn assign(&self, id: &str, assignment: &str, status: Option<&str>, current: bool) {
self.authority.store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','owner-account','1')",params![id,assignment])?;
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,tracking_updated_at=?2 WHERE workspace_id='w' AND assignment_id=?3",
params![status.map(|s| serde_json::json!({"status":s,"waiting_reason":null,"progress":null,"next_action":null,"result":null}).to_string()),status.map(|_|"1"),assignment])?;
if current {
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
VALUES('w',?1,'orchestrator',?2,'workspace_agent','workspace-orchestrator','1')",params![id,assignment])?;
}
Ok(())
}).unwrap();
}
fn event(&self, id: &str, index: i64, body: &str, archived: bool) {
self.authority.store.with_conn(|conn| {
if index == -1 {
conn.execute("UPDATE typed_ticket_events SET body=?1 WHERE workspace_id='w' AND ticket_id=?2 AND event_index=-1",params![body,id])?;
} else {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref,archived)
VALUES('w',?1,?2,'comment',?3,?4,?5)",params![id,index,body,format!("{id}-event-{index}"),archived])?;
}
Ok(())
}).unwrap();
}
}
fn agent() -> ticket::TicketAssignmentPrincipalSnapshot {
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
}
}
fn ids(response: &TicketBrowseQueryResponse) -> Vec<String> {
response.items.iter().map(|item| item.id.clone()).collect()
}
#[tokio::test]
async fn ticket_browse_unicode_search_uses_same_case_mapping_before_pagination() {
let f = Fixture::new().await;
f.ticket("newer nonmatch", Some("9"));
let title = f.ticket("ÉCOLE", Some("4"));
let body = f.ticket("body", Some("3"));
f.event(&body, -1, "ÜBER", false);
let thread = f.ticket("thread", Some("2"));
f.event(&thread, 0, "ΜΉΝΥΜΑ", false);
let closure = f.ticket("closure", Some("1"));
f.closure(
&closure,
"unicode-close",
TicketClosureReason::Other("ÖFFEN".into()),
true,
);
let special = f.ticket("special", Some("0"));
f.event(&special, 0, "ascii\0ÜNIT Kelvin", false);
for (needle, expected) in [
("ünit", &special),
("kelvin", &special),
("ÉCOLE", &title),
("école", &title),
("ÜBER", &body),
("über", &body),
("ΜΉΝΥΜΑ", &thread),
("μήνυμα", &thread),
("ÖFFEN", &closure),
("öffen", &closure),
] {
let response = f.run(TicketBrowseQueryRequest {
query: Some(needle.into()),
limit: Some(1),
..Default::default()
});
assert_eq!(ids(&response), vec![expected.clone()], "{needle}");
assert!(!response.page.has_more);
}
}
#[tokio::test]
async fn ticket_browse_keyset_ties_null_dates_and_scoped_canonical_cursors() {
let f = Fixture::new().await;
let mut tied = vec![
f.ticket("tie 1", Some("2")),
f.ticket("tie 2", Some("2")),
f.ticket("tie 3", Some("2")),
];
tied.sort();
let newer = f.ticket("new", Some("3"));
let older = f.ticket("old", Some("1"));
let empty = f.ticket("empty", Some(""));
let mut nulls = vec![f.ticket("null 1", None), f.ticket("null 2", None)];
nulls.sort();
let mut expected = vec![newer];
expected.extend(tied);
expected.extend([older, empty]);
expected.extend(nulls);
let mut query = TicketBrowseQueryRequest {
states: vec![TicketState::Closed, TicketState::Open, TicketState::Open],
limit: Some(2),
..Default::default()
};
let first = f.run(query.clone());
assert_eq!(first.page.returned, 2);
assert!(first.page.has_more);
assert!(!first.page.source_truncated);
assert_eq!(first.page.source_limit, None);
let cursor = first.page.next_cursor.clone();
let mut seen = ids(&first);
query.states = vec![TicketState::Open, TicketState::Closed];
query.sort = Some("updated_desc".into());
query.limit = Some(3);
query.cursor = cursor.clone();
loop {
let page = f.run(query.clone());
seen.extend(ids(&page));
if !page.page.has_more {
assert!(page.page.next_cursor.is_none());
break;
}
assert_eq!(page.items.len(), 3);
query.cursor = page.page.next_cursor;
}
assert_eq!(seen, expected);
let changed = TicketBrowseQueryRequest {
cursor: cursor.clone(),
states: vec![TicketState::Closed],
..Default::default()
};
assert!(f.authority.query_ticket_browse(changed).is_err());
let foreign =
SqliteWorkspaceAuthority::new(f._dir.path().join("browse.db"), "foreign").unwrap();
assert!(
foreign
.query_ticket_browse(TicketBrowseQueryRequest {
cursor: cursor.clone(),
states: vec![TicketState::Open, TicketState::Closed],
..Default::default()
})
.is_err()
);
assert!(
f.authority
.query_tickets(server_api::TicketQueryRequest {
cursor,
..Default::default()
})
.is_err()
);
for sort in ["title", "relevance", "created_desc"] {
assert!(
f.authority
.query_ticket_browse(TicketBrowseQueryRequest {
sort: Some(sort.into()),
..Default::default()
})
.is_err()
);
}
assert!(
f.authority
.query_ticket_browse(TicketBrowseQueryRequest {
cursor: Some("v1:garbage".into()),
..Default::default()
})
.is_err()
);
}
#[tokio::test]
async fn ticket_browse_all_filters_are_applied_before_limit_and_batch_fields_match() {
let f = Fixture::new().await;
for i in 0..8 {
f.ticket(&format!("irrelevant {i}"), Some("9"));
}
let one = f.ticket("Needle one", Some("2"));
let two = f.ticket("Needle two", Some("1"));
let partial = f.ticket("Needle partial", Some("8"));
for id in [&one, &two, &partial] {
f.tag(id, "a");
f.closure(
id,
&format!("close-{id}"),
TicketClosureReason::Other("custom reason".into()),
true,
);
f.assign(id, &format!("assignment-{id}"), Some("done"), true);
}
f.tag(&one, "b");
f.tag(&two, "b");
let query = TicketBrowseQueryRequest {
query: Some("NEEDLE".into()),
states: vec![TicketState::Closed],
tag_ids: vec!["b".into(), "a".into(), "a".into()],
assigned_principal: Some(agent()),
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Done),
closure_reason: Some(TicketClosureReason::Other("custom reason".into())),
limit: Some(1),
..Default::default()
};
let first = f.run(query.clone());
assert_eq!(ids(&first), vec![one.clone()]);
assert!(first.page.has_more);
assert_eq!(
first.items[0]
.tags
.iter()
.map(|tag| tag.id.as_str())
.collect::<Vec<_>>(),
vec!["a", "b"]
);
let detail = f.authority.ticket(&one).unwrap();
assert_eq!(
first.items[0].assignment_tracking,
detail.assignment_tracking
);
assert_eq!(first.items[0].current_closure, detail.current_closure);
let second = f.run(TicketBrowseQueryRequest {
cursor: first.page.next_cursor,
tag_ids: vec!["a".into(), "b".into()],
query: Some("needle".into()),
..query.clone()
});
assert_eq!(ids(&second), vec![two]);
assert!(!second.page.has_more);
for missing in ["missing", "deleted"] {
assert!(
f.run(TicketBrowseQueryRequest {
tag_ids: vec![missing.into()],
..query.clone()
})
.items
.is_empty()
);
}
f.authority
.store
.with_conn(|conn| {
conn.execute(
"UPDATE workspace_tags SET deleted=1 WHERE workspace_id='w' AND tag_id='b'",
[],
)?;
Ok(())
})
.unwrap();
assert!(f.run(query).items.is_empty());
}
#[tokio::test]
async fn ticket_browse_assignment_current_missing_unknown_and_cleared_semantics() {
let f = Fixture::new().await;
let unassigned = f.ticket("unassigned", Some("1"));
let unknown = f.ticket("unknown", Some("1"));
let cleared = f.ticket("cleared", Some("1"));
let current = f.ticket("current", Some("1"));
f.assign(&unknown, "unknown", None, true);
f.authority.store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w',?1,'orchestrator','legacy-assign','assigned','unknown','owner-account','1')",[&unknown])?;
Ok(())
}).unwrap();
f.assign(&cleared, "cleared", Some("done"), true);
f.authority.store.with_conn(|conn| {
conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND assignment_id='cleared'",[])?; Ok(())
}).unwrap();
f.assign(&current, "historical", Some("done"), false);
f.assign(&current, "current", Some("waiting"), true);
let all = f.run(Default::default());
assert_eq!(all.items.len(), 4);
for id in [&unassigned, &cleared] {
assert!(
all.items
.iter()
.find(|item| &item.id == id)
.unwrap()
.assignment_tracking
.is_empty()
);
}
assert!(
all.items
.iter()
.find(|item| item.id == unknown)
.unwrap()
.assignment_tracking[0]
.tracking
.is_none()
);
let unknown_projection = &all
.items
.iter()
.find(|item| item.id == unknown)
.unwrap()
.assignment_tracking;
assert_eq!(
unknown_projection[0].last_event_id.as_deref(),
Some("legacy-assign")
);
assert_eq!(
*unknown_projection,
f.authority.ticket_assignment_tracking(&unknown).unwrap()
);
let mut assigned = ids(&f.run(TicketBrowseQueryRequest {
assigned_principal: Some(agent()),
..Default::default()
}));
assigned.sort();
let mut expected = vec![current.clone(), unknown];
expected.sort();
assert_eq!(assigned, expected);
assert!(
f.run(TicketBrowseQueryRequest {
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Done),
..Default::default()
})
.items
.is_empty()
);
assert_eq!(
ids(&f.run(TicketBrowseQueryRequest {
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Waiting),
..Default::default()
})),
vec![current]
);
for principal in [
ticket::TicketAssignmentPrincipalSnapshot::User {
account_id: "missing".into(),
},
ticket::TicketAssignmentPrincipalSnapshot::Worker {
runtime_id: "missing".into(),
worker_id: "missing".into(),
},
] {
assert!(
f.run(TicketBrowseQueryRequest {
assigned_principal: Some(principal),
..Default::default()
})
.items
.is_empty()
);
}
}
#[tokio::test]
async fn ticket_browse_search_literal_title_initial_thread_and_closure_history() {
let f = Fixture::new().await;
let title = f.ticket("Literal %_ title", Some("1"));
let body = f.ticket("body", Some("1"));
f.event(&body, -1, "Initial %_ BODY", false);
let thread = f.ticket("thread", Some("1"));
f.event(&thread, 80, "Thread %_ needle", false);
let historical = f.ticket("historical", Some("1"));
f.closure(
&historical,
"old",
TicketClosureReason::Other("%_ custom".into()),
true,
);
f.closure(&historical, "new", TicketClosureReason::Completed, false);
let archived = f.ticket("archived", Some("1"));
f.event(&archived, 90, "Archived %_", true);
f.ticket("anything", Some("1"));
let mut expected = vec![title, body, thread, historical.clone()];
expected.sort();
let result = f.run(TicketBrowseQueryRequest {
query: Some("%_".into()),
..Default::default()
});
assert_eq!(ids(&result), expected);
assert!(
result
.items
.iter()
.find(|item| item.id == historical)
.unwrap()
.current_closure
.is_none()
);
assert_eq!(
ids(&f.run(TicketBrowseQueryRequest {
query: Some("other %_ custom".into()),
..Default::default()
})),
vec![historical]
);
assert!(
f.run(TicketBrowseQueryRequest {
closure_reason: Some(TicketClosureReason::Other("%_ custom".into())),
..Default::default()
})
.items
.is_empty()
);
assert!(
f.run(TicketBrowseQueryRequest {
query: Some("archived %_".into()),
..Default::default()
})
.items
.is_empty()
);
}
#[tokio::test]
async fn ticket_browse_current_closure_not_prior_or_reopened_and_workspace_isolation() {
let f = Fixture::new().await;
let id = f.ticket("same identity", Some("1"));
f.closure(&id, "previous", TicketClosureReason::Completed, true);
f.closure(&id, "current", TicketClosureReason::NotPlanned, true);
assert!(
f.run(TicketBrowseQueryRequest {
closure_reason: Some(TicketClosureReason::Completed),
..Default::default()
})
.items
.is_empty()
);
assert_eq!(
ids(&f.run(TicketBrowseQueryRequest {
closure_reason: Some(TicketClosureReason::NotPlanned),
..Default::default()
})),
vec![id.clone()]
);
f.authority.store.with_conn(|conn| {
let columns: Vec<String> = conn.prepare("PRAGMA table_info(typed_tickets)")?.query_map([],|row| row.get(1))?.collect::<rusqlite::Result<_>>()?;
let expressions = columns.iter().map(|name| match name.as_str() { "workspace_id" => "'foreign'".into(), "title" => "'foreign secret'".into(), _ => format!("\"{name}\"") }).collect::<Vec<_>>().join(",");
conn.execute(&format!("INSERT INTO typed_tickets SELECT {expressions} FROM typed_tickets WHERE workspace_id='w' AND ticket_id=?1"),[&id])?;
conn.execute("INSERT INTO workspace_resource_keys(workspace_id,resource_kind,resource_id,sequence,resource_key,allocated_at) VALUES('foreign','ticket',?1,1,'T-1','1')",[&id])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref,archived) VALUES('foreign',?1,-1,'comment','foreign secret','foreign-event',0)",[&id])?;
conn.execute("INSERT INTO workspace_tags(workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES('foreign','foreign-tag','Foreign','foreign','tag-op',0)",[])?;
conn.execute("INSERT INTO typed_ticket_tags(workspace_id,ticket_id,tag_id) VALUES('foreign',?1,'foreign-tag')",[&id])?;
Ok(())
}).unwrap();
let local = f.run(Default::default());
assert_eq!(local.items.len(), 1);
assert!(local.items[0].tags.is_empty());
assert!(
f.run(TicketBrowseQueryRequest {
query: Some("foreign secret".into()),
..Default::default()
})
.items
.is_empty()
);
assert!(
f.run(TicketBrowseQueryRequest {
tag_ids: vec!["foreign-tag".into()],
..Default::default()
})
.items
.is_empty()
);
let foreign =
SqliteWorkspaceAuthority::new(f._dir.path().join("browse.db"), "foreign").unwrap();
let foreign = foreign.query_ticket_browse(Default::default()).unwrap();
assert_eq!(foreign.items[0].title, "foreign secret");
assert!(foreign.items[0].current_closure.is_none());
assert_eq!(foreign.items[0].tags[0].id, "foreign-tag");
}
#[tokio::test]
async fn ticket_browse_user_worker_principals_are_exact_current_identity_not_handling() {
let f = Fixture::new().await;
let id = f.ticket("current roles", Some("1"));
f.assign(&id, "orchestrator", Some("waiting"), true);
f.authority.store.with_conn(|conn| {
conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','runtime','worker','Fixture','normal','1','1')",[])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w',?1,'owner','owner','user','owner-account','owner-account','1')",[&id])?;
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w',?1,'owner','owner','user','owner-account','1')",[&id])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w',?1,'worker','worker','worker','runtime','worker','owner-account','1')",[&id])?;
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) VALUES('w',?1,'worker','worker','worker','runtime','worker','1')",[&id])?;
Ok(())
}).unwrap();
for principal in [
ticket::TicketAssignmentPrincipalSnapshot::User {
account_id: "owner-account".into(),
},
ticket::TicketAssignmentPrincipalSnapshot::Worker {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
},
] {
let query = TicketBrowseQueryRequest {
assigned_principal: Some(principal),
..Default::default()
};
let result = f.run(query.clone());
assert_eq!(ids(&result), vec![id.clone()]);
assert_eq!(result.items[0].assignment_tracking.len(), 1);
assert!(
f.run(TicketBrowseQueryRequest {
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Waiting),
..query
})
.items
.is_empty()
);
}
assert!(
f.run(TicketBrowseQueryRequest {
assigned_principal: Some(ticket::TicketAssignmentPrincipalSnapshot::Worker {
runtime_id: "different-runtime".into(),
worker_id: "worker".into()
}),
..Default::default()
})
.items
.is_empty()
);
}
#[tokio::test]
async fn ticket_browse_plain_page_sql_uses_recent_index_without_history_or_graph() {
let f = Fixture::new().await;
f.ticket("first", Some("1"));
let (sql, values) = page_sql("w", &TicketBrowseQueryRequest::default(), None, 30);
for forbidden in [
"typed_ticket_events",
"typed_ticket_case_history",
"typed_ticket_relations",
"merge_request",
"tracking_history",
] {
assert!(!sql.contains(forbidden), "{sql}");
}
f.authority
.store
.with_conn(|conn| {
let plans: Vec<String> = conn
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?
.query_map(params_from_iter(values.iter()), |row| row.get(3))?
.collect::<rusqlite::Result<_>>()?;
assert!(
plans
.iter()
.any(|plan| plan.contains("typed_tickets_workspace_updated")),
"{plans:?}"
);
assert!(
!plans.iter().any(|plan| plan.contains("TEMP B-TREE")),
"{plans:?}"
);
Ok(())
})
.unwrap();
}
+9 -3
View File
@@ -12,7 +12,7 @@ Ticket case/handling, MR approval, Job execution, or Worker lifecycle.
| Ticket Assign, Answer, item/content edits | Ticket transaction triggers → `notification_routing` | Original delegation; explicit request vs attention. No recipient, released/history, closed/done, unsupported recipient and self-notification are recorded separately. | Assignment reservations, pinned requests, authorized targets and ACKs; common recovery; `assignment_delivery_tests.rs` and `server_assignment_delivery_tests.rs`. |
| Ticket comments/post edits, tags, case, tracking, release | Same capture and routing ledger | They do not become new Orchestrator requests. Applicable implementation-Worker attention is distinct from new delegation. | Same evidence; `notification_routing_tests.rs` covers positive and negative routes. Unknown source kinds defer explicitly. |
| MR review requested/result/revoked/cancelled/merge and observed source changes | MR transaction/source-observation triggers → common routing | Ticket delegation and implementation recipient captured at the event. MR remains the freshness/approval authority. | Stable MR event identity; current MR must be reread before action. Ordinary review comments are not inferred handoffs. `implementation_result_tests.rs` and `server_implementation_result_tests.rs` cover capture, self-suppression, recovery and public diagnostics. |
| Explicit implementation result/handoff/blocker | Authenticated `TicketImplementationResult` transaction | Implementation assignment and its pinned Orchestrator delegation, with exact-replay identity. No substitution of AssignmentAnswer or self-reported handling. | Result and source persist together; same common delivery/receipt path. No MR is required. |
| Explicit implementation result/handoff/blocker | Authenticated `TicketImplementationResult` transaction | Authenticated Workspace Worker and Ticket, without assignment membership requirements. The current Orchestrator is captured at report-save time, or no-recipient is recorded. No substitution of AssignmentAnswer or self-reported handling. | Result and source persist together; same common delivery/receipt path. No MR is required. |
| Backend Job accepted result | Result transaction → `notification_routing::jobs` | Fixed recipient, never replacement by the current Orchestrator. No target, checker no-findings and stale content suppress without ACK. Domain validation/rendering remains in the adapter. | Common request/ACK/failure ledger, fixed bytes/recipient, startup and periodic recovery; `job_notification_tests.rs` and `server_job_notification_tests.rs`. |
| Explicit WorkerNotify/WorkerSendInput | Authenticated caller | Explicit communication, not automatic business routing. | Existing Worker control boundary; not inferred from Ticket activity. |
| Internal SubWorker → parent / Web subscriptions | Parent-owned execution / UI synchronization | Separate ownership and authorization. | Not migrated into business-notification routing. |
@@ -31,8 +31,14 @@ uses real Git publication and signed HTTP to cover the opening baseline, a later
source change before review, and result reporting against observation-only evidence.
Schema 96 migrates Job notifications; schema 97 installs MR/implementation-result
capture. Legacy Job ACKs and suppressions remain distinct. Ambiguous legacy
implementation ownership remains unknown; comments never synthesize a result.
capture. Schema 98 removes assignment ownership and caller-supplied assignment IDs
from result reporting, preserving prior result bytes and notification evidence.
Legacy Job ACKs and suppressions remain distinct; comments never synthesize a result.
The model supplies only Ticket, kind, body and optional MR evidence. Host-side tools
use the durable Tool call identity as the transport replay key. Retrying the same
invocation cannot duplicate a report; a new invocation is a new report, so an unknown
outcome must not be retried blindly with a fresh call. Stored notifications retain
their captured recipient rather than being reassigned during delivery recovery.
Preparation failures remain retryable and visible after recovery; durable fair
scheduling prevents repeated failures from starving healthy or already-pinned work.
@@ -0,0 +1,55 @@
# Tickets browse: component performance comparison
## Change
The Web Tickets list uses `POST /api/w/{workspace_id}/tickets/browse`, a deliberately narrow display contract. The existing `/tickets/query` and Ticket detail paths retain authoritative MR evidence observation; the browse result contains no evidence/approval fields and is not an integration-readiness attestation.
A nonempty browse page uses one SQLite read transaction with four SELECT statements: filtered page selection, active tags, current closure judgments, and current Orchestrator assignment tracking. No full Ticket/event reconstruction, dependency graph, provider Git observation, or per-row enrichment query is performed. All filters precede LIMIT; cursors bind Workspace, canonical filters, sort and the last row, including NULL timestamps and ID ties.
The Web loader waits only for the list (and tag-name resolution when requested). Orchestrator status loads independently after mount; repositories load on opening Add Ticket. Late responses are fenced against route/Workspace changes.
## Backend measurement
Run the retained benchmark:
```sh
cargo test -p yoi-workspace-server ticket_browse_component_performance --lib -- --ignored --nocapture
```
Optional fixture sizes: `BROWSE_BENCH_TICKETS` and `BROWSE_BENCH_EVENTS`.
- Real `SqliteWorkspaceAuthority` methods and file-backed disposable SQLite; no production DB, HTTP server, Runtime, provider network, or live Git.
- 1,000 generated open Tickets, each with 100 discussion events plus the initial post, roughly 1 KiB text per event, event attributes/references and one relation. One additional seed Ticket is used for valid references.
- Compare the former browser path (`query_tickets`, preserved for authoritative queries) with `query_ticket_browse` on the same fixture.
- Fixture construction excluded; both paths warmed, then three old/new pairs measured through JSON serialization. Assert identical displayed items/order and has-more before accepting results.
- Debug test build on a shared development host; these are component measurements, not production response-time estimates. Run-to-run variation is substantial; no flaky time threshold is asserted.
Adopted Unicode-aware implementation with borrowed scalar-function inputs, median milliseconds:
| Case | Former browser path | Browse path | Response bytes, old → new |
|---|---:|---:|---:|
| Initial 30-row list | 23,265.888 | 0.769 | 29,744 → 5,647 |
| Dense discussion-text match | 23,121.110 | 16.130 | 52,163 → 5,646 |
| Sparse title match (`Ticket 00099`, 10 rows) | 7,314.318 | 496.367 | 10,076 → 1,968 |
| No match (`absent-needle`) | 299.065 | 409.180 | 184 → 186 |
The ordinary list removes the unnecessary work rather than caching evidence. Dense matching stops after enough rows; sparse/no-match searches still scan text across candidates. **No-match search regresses by about 110 ms in this fixture.** The browse path fixes the old SQL/Rust Unicode case-fold mismatch using the same Rust lowercase operation for the needle and stored text, at callback cost. An ASCII SQL length-check fast path was measured and removed because it increased scan cost. A persistent search index is not introduced in this change; large sparse/no-match searches remain a limitation, not a claimed improvement.
## Web component measurement
```sh
cd web/workspace
deno task test:component 'src/routes/w/[workspaceId]/tickets/ticket-list.browser.spec.ts'
```
The real Svelte component and new loader run against controlled promises, with 20 ms list latency and 100 ms auxiliary-request latency. The comparison restores the former `Promise.all` auxiliary gate for the baseline; it does **not** simulate the old backend SQL. One focused run measured 103.17 ms with the gate versus 23.48 ms through row rendering without it. There are no live requests or hard timing thresholds. Separate assertions verify visible rows while status is unresolved and no repository fetch before Add Ticket.
## Regression evidence
- Workspace-server library: 1,088 passed, 2 ignored (including the separately run timing benchmark).
- Backend browse tests: keyset pagination, filter-before-limit, tag/current-assignment/closure parity, Workspace isolation, literal `%`/`_`, Unicode title/body/thread/reason search and embedded NUL.
- Actual HTTP route: response shape, unauthenticated rejection, invalid filters and foreign Workspace rejection.
- Counting source observer: browse makes zero calls; authoritative QueryTicket and show still observe open MR sources.
- Web unit tests: 644 passed. Svelte components: 401 passed. Svelte check: no errors or warnings.
No production migration or restart is needed for the schema itself (no schema change); serving the new endpoint and Web code still requires the normal software update.
+257 -9
View File
@@ -11605,6 +11605,164 @@
],
"type": "object"
},
"TicketBrowseQueryItem": {
"additionalProperties": false,
"properties": {
"assignment_tracking": {
"items": {
"$ref": "#/components/schemas/TicketAssignmentTrackingProjection"
},
"type": "array"
},
"current_closure": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketClosureJudgment"
},
{
"type": "null"
}
]
},
"id": {
"type": "string"
},
"resource_key": {
"type": "string"
},
"state": {
"$ref": "#/components/schemas/BrowserTicketState"
},
"tags": {
"items": {
"$ref": "#/components/schemas/WorkspaceTag"
},
"type": "array"
},
"title": {
"type": "string"
},
"updated_at": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"resource_key",
"title",
"state",
"tags",
"assignment_tracking"
],
"type": "object"
},
"TicketBrowseQueryRequest": {
"additionalProperties": false,
"properties": {
"assigned_principal": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketAssignmentPrincipalSnapshot"
},
{
"type": "null"
}
]
},
"closure_reason": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketClosureReason"
},
{
"type": "null"
}
],
"description": "Exact current closed-case reason; historical reasons do not match this filter."
},
"cursor": {
"description": "Browse-only cursor bound to the Workspace, canonical filters, and sort.",
"type": [
"string",
"null"
]
},
"handling_status": {
"anyOf": [
{
"$ref": "#/components/schemas/TicketAssignmentTrackingStatus"
},
{
"type": "null"
}
]
},
"limit": {
"format": "uint",
"maximum": 1000,
"minimum": 0,
"type": [
"integer",
"null"
]
},
"query": {
"type": [
"string",
"null"
]
},
"sort": {
"description": "Only updated_desc is supported; defaults to updated_desc.",
"type": [
"string",
"null"
]
},
"states": {
"default": [],
"items": {
"$ref": "#/components/schemas/TicketState"
},
"type": "array"
},
"tag_ids": {
"default": [],
"description": "Every supplied active tag ID must match (AND).",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"TicketBrowseQueryResponse": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/TicketBrowseQueryItem"
},
"type": "array"
},
"page": {
"$ref": "#/components/schemas/QueryPage"
},
"record_authority": {
"type": "string"
}
},
"required": [
"items",
"page",
"record_authority"
],
"type": "object"
},
"TicketCaseAction": {
"description": "Close requires an open case; reopen and correction require a closed case.\nReopen clears the current judgment but retains its history and last closure key.\nCorrection appends a new judgment without rewriting the old one.",
"oneOf": [
@@ -12734,9 +12892,6 @@
"TicketImplementationResultRequest": {
"additionalProperties": false,
"properties": {
"assignment_id": {
"type": "string"
},
"body": {
"type": "string"
},
@@ -12754,16 +12909,12 @@
]
},
"operation_key": {
"type": "string"
},
"orchestrator_assignment_id": {
"description": "Host-owned replay identity; model-facing tools derive it from the Tool call.",
"type": "string"
}
},
"required": [
"operation_key",
"assignment_id",
"orchestrator_assignment_id",
"kind",
"body"
],
@@ -17582,7 +17733,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c"
"x-yoi-source-digest": "sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f"
},
"openapi": "3.1.0",
"paths": {
@@ -34661,6 +34812,103 @@
]
}
},
"/api/w/{workspace_id}/tickets/browse": {
"post": {
"operationId": "ticket_browse",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketBrowseQueryRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketBrowseQueryResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/tickets/default-intake-ready-body": {
"post": {
"operationId": "ticket_default_intake_ready_body",
+1 -1
View File
@@ -43,7 +43,7 @@
};
complete = {
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using your current implementation assignment, original Orchestrator delegation and stable operation_key. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using the Ticket, stable operation_key, kind, bounded body and optional retained MR/source evidence. Backend authenticates an active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
transitions = {
completed = {
target = "done";
+1 -1
View File
@@ -1,6 +1,6 @@
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Use `TicketImplementationResult` for important handoffs, results and blockers to the original Orchestrator delegation. Read `ShowTicket` for your implementation assignment and `TicketAssignmentTrackingQuery` for the current delegation; supply both identities, a stable operation_key, kind and bounded body. Backend verifies your authenticated implementation identity; do not substitute `TicketAssignmentAnswer`, an Orchestrator self-report, or a comment. Retry response loss with exactly the same key and full input. The receipt is result evidence, not Ticket completion or handling done. MR/source references are optional and must identify retained MR evidence; MR-free research and documents use the same operation. No ready/Queue step or mandatory terminal unassignment is part of code handoff.
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Use `TicketImplementationResult` for important handoffs, results and blockers with only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims, or substitute `TicketAssignmentAnswer`, an Orchestrator self-report, or a comment. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. The receipt is result evidence, not Ticket completion or handling done. MR/source references are optional and must identify retained MR evidence; MR-free research and documents use the same operation. No ready/Queue step or mandatory terminal unassignment is part of code handoff.
Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
+1 -1
View File
@@ -4,7 +4,7 @@ You are the Ticket Orchestrator role.
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied.
Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. A `mr-event:<MR ID>:<event ID>` notification means reread that MR; a review, revocation, cancellation or merge notification is not current approval or integration authority. An `implementation-result:<result ID>` input identifies a separately saved handoff: use `TicketImplementationResultQuery` with that Ticket/result ID. Compare its original delegation with the current assignment before acting. Neither notification creates a new request, closes the case, reports handling done or stops a Worker. Legacy comments are not typed results; unknown ownership requires explicit confirmation, not inferred handoff. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close.
Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. A `mr-event:<MR ID>:<event ID>` notification means reread that MR; a review, revocation, cancellation or merge notification is not current approval or integration authority. An `implementation-result:<result ID>` input identifies a separately saved report: use `TicketImplementationResultQuery` with that Ticket/result ID. Reports use authenticated active Workspace Worker identity and only Ticket, kind, body and optional retained MR/source evidence as input; no current or pinned implementation assignment or Orchestrator delegation is required. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. Other or previously assigned Workers may report facts, not fulfillment of an assignment or authority to act. Reread current Ticket, delegation and any referenced MR before an independently authorized action; do not infer approval or new work from the report. Neither notification creates a new request, closes the case, reports handling done or stops a Worker. Legacy comments are not typed results; unknown ownership requires explicit confirmation, not inferred handoff. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close.
Explicitly start bounded Ticket work with `SpawnTicketWorker`, the observed current `orchestrator_assignment_id`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. Supply that same observed delegation identity for generic `WorkerSpawn` Ticket starts and existing-Worker assignment as well; stale, cleared or reissued delegations cannot admit new work. Dependencies remain diagnostics, not a blanket refusal, and are never implicitly started. After start, reread the Ticket and verify the new Worker assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
+1 -1
View File
@@ -1,6 +1,6 @@
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult`, using your observed implementation assignment, its original Orchestrator delegation and a stable operation_key. MR/source references are optional; research and documents use the same operation without a repository. Retry unknown outcomes only with the same key and full input. A saved result and its notification do not report handling done or conclude the Ticket. Being the assigned Worker does not authorize `TicketAssignmentAnswer` or self-reporting as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult` using only the Ticket, kind, bounded body and optional MR evidence. Backend verifies an authenticated active Workspace Worker; no current or pinned implementation assignment or Orchestrator delegation is required. Other or previously assigned Workers may report facts without fulfilling an assignment or gaining authority. Do not supply assignment identities or model actor claims. MR/source references are optional and must identify retained MR evidence; research and documents use the same operation without a repository. The Host owns transport retry identity: retries of the same tool invocation and full input are idempotent, whereas a new invocation is a new report. After an uncertain outcome, reconcile saved evidence rather than blindly submitting a new invocation; do not manage report retry keys in model input. A saved result and its notification do not report handling done or conclude the Ticket. Being the assigned Worker does not authorize `TicketAssignmentAnswer` or self-reporting as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -137,6 +137,12 @@ export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProje
export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
export type TicketBrowseQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; current_closure?: TicketClosureJudgment | null; id: string; resource_key: string; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; updated_at?: string | null; };
export type TicketBrowseQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; closure_reason?: TicketClosureReason | null; cursor?: string | null; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; query?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; };
export type TicketBrowseQueryResponse = { items: Array<TicketBrowseQueryItem>; page: QueryPage; record_authority: string; };
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
@@ -169,7 +175,7 @@ export type TicketImplementationResultKind = "handoff" | "result" | "blocker";
export type TicketImplementationResultReceipt = { created_at: string; request: TicketImplementationResultRequest; result_id: string; ticket_id: string; };
export type TicketImplementationResultRequest = { assignment_id: string; body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; orchestrator_assignment_id: string; };
export type TicketImplementationResultRequest = { body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; };
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Input contract: 0.1.0 (sha256:04100376ce54b912df019137dbf7414f9cb2930293ee237a905bc6a5543c294f)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -2,6 +2,7 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import type { TicketBrowseQueryResponse } from "#lib/generated/ticket-api.ts";
import { loadJson } from "./http.ts";
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
@@ -12,6 +13,7 @@ import {
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
parseTicketBrowseQueryResponse,
parseTicketRecordRef,
parseTicketRoleAssignmentMutationResponse,
parseTicketSummary,
@@ -45,6 +47,97 @@ const page = {
source_truncated: false,
};
function browseFixture(): TicketBrowseQueryResponse {
return {
record_authority: "sqlite",
page: { ...page, has_more: true, next_cursor: "opaque-cursor" },
items: [{
id: "ticket-id", resource_key: "T-1", title: "Minimal browse row",
state: "open", updated_at: null, tags: [], current_closure: null,
assignment_tracking: [],
}],
};
}
Deno.test("Ticket browse accepts the generated minimal row and preserves cursor metadata without enriched query fields", () => {
const fixture = browseFixture();
const parsed = parseTicketBrowseQueryResponse(fixture);
assertEquals(parsed, { page: fixture.page, record_authority: "sqlite", items: fixture.items });
for (const field of ["evidence", "merge_requests", "content_digest", "snippet", "blocker_count", "current_worker"]) {
assertEquals(field in parsed.items[0], false);
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: null }] }), "unknown field");
}
assertEquals(parseTicketBrowseQueryResponse({ ...fixture, items: [], page: { ...page, returned: 0 } }).items, []);
});
Deno.test("Ticket browse preserves case, tags and self-reported handling independently", () => {
const fixture = browseFixture();
const assignment = {
workspace_id: "workspace-a", ticket_id: "ticket-id", assignment_id: "delegation-1",
role: "orchestrator", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
assigned_by: "user:alice", assigned_at: "2026-10-15T00:00:00Z", current: true,
release: null, last_event_id: "event-1", updated_at: null, migration_diagnostic: null,
tracking: { status: "done", waiting_reason: null, progress: "Investigated", next_action: "Human decision", result: "Options returned" },
};
for (const state of ["open", "closed"]) {
const row = parseTicketBrowseQueryResponse({
...fixture,
items: [{ ...fixture.items[0], state, tags, current_closure: state === "closed" ? closure : null, assignment_tracking: [assignment] }],
}).items[0];
assertEquals(row.state, state);
assertEquals(row.tags, tags);
for (const [field, expected] of Object.entries(assignment)) {
assertEquals((row.assignment_tracking[0] as unknown as Record<string, unknown>)[field], expected);
}
assertEquals(row.current_closure, state === "closed" ? closure : null);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], assignment_tracking: [{ ...assignment, tracking: { status: "running" } }] }] }), "status is invalid");
});
Deno.test("Ticket browse rejects missing required fields, legacy states and malformed row values", () => {
const fixture = browseFixture();
for (const field of ["id", "resource_key", "title", "state", "tags", "assignment_tracking"]) {
const row: Record<string, unknown> = { ...fixture.items[0] };
delete row[field];
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [row] }), `items[0].${field}`);
}
for (const [field, value] of [
["id", 1], ["title", false], ["state", "done"], ["state", "queued"],
["updated_at", 7], ["tags", null], ["tags", [{ ...tags[0], id: " " }]],
["assignment_tracking", {}], ["current_closure", { ...closure, reason: "done" }],
] as const) {
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], [field]: value }] }), `items[0].${field}`);
}
});
Deno.test("Ticket browse validates envelope, pagination and collection bounds", () => {
const fixture = browseFixture();
for (const field of ["items", "page", "record_authority"]) {
const wire: Record<string, unknown> = { ...fixture };
delete wire[field];
assertThrows(() => parseTicketBrowseQueryResponse(wire), `response.${field}`);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, unexpected: true }), "unknown field");
for (const [field, value] of [["has_more", "yes"], ["next_cursor", 42], ["limit", -1], ["returned", 1.5], ["source_truncated", null]] as const) {
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, [field]: value } }), `page.${field}`);
}
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, page: { ...fixture.page, extra: true } }), "unknown field");
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: Array(4097).fill(fixture.items[0]) }), "items exceeds its item limit");
assertThrows(() => parseTicketBrowseQueryResponse({ ...fixture, items: [{ ...fixture.items[0], title: "x".repeat(262145) }] }), "title exceeds its length limit");
});
Deno.test("Ticket browse load diagnostics identify the malformed field without exposing row content", async () => {
const fixture = browseFixture();
const result = await loadJson(
(async () => Response.json({ ...fixture, items: [{ ...fixture.items[0], title: "PRIVATE_TITLE", tags: null }] })) as typeof fetch,
"/api/w/workspace-a/tickets/browse", { method: "POST" },
parseTicketBrowseQueryResponse, TICKET_BROWSER_API_LOAD_POLICY,
);
assertEquals(result.data, null);
assertEquals(result.error, "Ticket Browser API: Ticket browse response.items[0].tags must be an array");
assertEquals(result.error?.includes("PRIVATE_TITLE"), false);
});
Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => {
const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" });
const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } });
@@ -41,6 +41,8 @@ import type {
TicketListItemSummary,
TicketListResponse,
TicketMergeRequestSummary,
TicketBrowseQueryItem,
TicketBrowseQueryResponse,
TicketQueryItem,
TicketQueryResponse,
TicketQueueOutcome,
@@ -1776,6 +1778,37 @@ export function parseMergeRequestDetailResponse(
};
}
export function parseTicketBrowseQueryResponse(value: unknown): TicketBrowseQueryResponse {
const label = "Ticket browse response";
const page = object(value, label);
exact(page, ["items", "page", "record_authority"], label);
return {
page: parseQueryPage(page.page, `${label}.page`),
record_authority: string(page.record_authority, `${label}.record_authority`),
items: array(page.items, `${label}.items`, (value, label): TicketBrowseQueryItem => {
const item = object(value, label);
exact(item, [
"id", "resource_key", "title", "state", "updated_at", "tags",
"current_closure", "assignment_tracking",
], label);
return {
id: string(item.id, `${label}.id`),
resource_key: string(item.resource_key, `${label}.resource_key`),
title: string(item.title, `${label}.title`),
state: parseTicketState(item.state, `${label}.state`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
current_closure: item.current_closure == null
? item.current_closure as null | undefined
: parseClosure(item.current_closure, `${label}.current_closure`),
assignment_tracking: array(
item.assignment_tracking, `${label}.assignment_tracking`, parseAssignmentTrackingProjection,
),
};
}),
};
}
export function parseTicketQueryResponse(value: unknown): TicketQueryResponse {
const label = "Ticket query response";
const page = object(value, label);
@@ -391,7 +391,7 @@ Deno.test("workspace Tickets surface provides query list and lifecycle controls"
// クエリ・ページングの振舞いはticket-search.testとTicket browser suiteで検証する。
assert(
ticketsLoad.includes("compileTicketSearch") &&
ticketsLoad.includes("/tickets/query") &&
ticketsLoad.includes("/tickets/browse") &&
ticketsPage.includes('class="ticket-list"') &&
!ticketsPage.includes("TicketQueryFilters") &&
!ticketsPage.includes("ticket-kanban"),
@@ -1,7 +1,7 @@
import type {
TicketAssignmentPrincipalSnapshot,
TicketClosureReason,
TicketQueryRequest,
TicketBrowseQueryRequest,
WorkspaceTag,
} from "../../generated/ticket-api.ts";
@@ -114,15 +114,15 @@ function reason(value: string, quoted: boolean): TicketClosureReason {
export function compileTicketSearch(
input: string,
tags: readonly WorkspaceTag[],
): TicketQueryRequest {
const request: TicketQueryRequest = {
): TicketBrowseQueryRequest {
const request: TicketBrowseQueryRequest = {
query: null,
states: [],
tag_ids: [],
assigned_principal: null,
handling_status: null,
sort: "updated_desc",
limit: 30 as TicketQueryRequest["limit"],
limit: 30 as TicketBrowseQueryRequest["limit"],
cursor: null,
};
const text: string[] = [];
@@ -1,8 +1,6 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import {
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "#lib/workspace/api/ticket-browser.ts";
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
@@ -12,7 +10,7 @@ import {
ticketSearchNeedsTags,
} from "#lib/workspace/tickets/ticket-search.ts";
import type {
TicketQueryRequest,
TicketBrowseQueryRequest,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
@@ -22,7 +20,7 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
const workspaceId = params.workspaceId;
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
const loadTickets = async () => {
let queryRequest: TicketQueryRequest | null = null;
let queryRequest: TicketBrowseQueryRequest | null = null;
try {
const tags: WorkspaceTag[] = [];
// タグ名をstable IDへ解決する場合だけ一覧を取得する。状態検索をタグAPIに依存させない。
@@ -54,13 +52,13 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
queryRequest = compileTicketSearch(search, tags);
const tickets = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
workspaceApiPath(workspaceId, "/tickets/browse"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(queryRequest),
},
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return { queryRequest, tickets };
@@ -74,15 +72,6 @@ export const load = deferPage(async ({ fetch, params, url }: Parameters<PageLoad
};
}
};
const [result, orchestrator, repositories] = await Promise.all([
loadTickets(),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return { workspaceId, search, ...result, orchestrator, repositories };
const result = await loadTickets();
return { workspaceId, search, ...result };
});
@@ -10,15 +10,17 @@
workspaceApiPath,
} from "#lib/workspace/api/http.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import type { RepositoryListResponse } from "#lib/generated/repository-api.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
parseTicketRecordRef,
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
TicketQueryItem as TicketSummary,
TicketBrowseQueryItem as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
@@ -44,19 +46,33 @@
let searchBusy = $state(false);
let pageBusy = $state(false);
let navigationError = $state<string | null>(null);
let routeGeneration = 0;
$effect(() => {
const currentData = data;
const generation = ++routeGeneration;
untrack(() => {
response = currentData.tickets;
searchText = currentData.search;
searchBusy = false;
pageBusy = false;
navigationError = null;
orchestrator = currentData.orchestrator;
orchestrator = { data: null, error: null };
orchestratorLoading = true;
orchestratorStarting = false;
repositoryResponse = { data: null, error: null };
repositoriesLoading = false;
creatingTicket = false;
createBusy = false;
createError = null;
createTitle = "";
createBody = "";
createTargets = [];
void loadOrchestrator(currentData, generation);
});
return () => { ++routeGeneration; };
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>({ data: null, error: null });
let orchestratorLoading = $state(true);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
@@ -64,8 +80,11 @@
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
let repositoryResponse = $state<ApiResult<RepositoryListResponse>>({ data: null, error: null });
let repositoriesLoading = $state(false);
const repositories = $derived(repositoryResponse.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
if (repositoriesLoading || !repositoryResponse.data) return false;
const keys = new Set<string>();
for (const target of createTargets) {
if (
@@ -77,9 +96,42 @@
}
return true;
});
function isCurrent(observedData: PageData, generation: number): boolean {
return data === observedData && routeGeneration === generation;
}
async function loadOrchestrator(observedData: PageData, generation: number): Promise<void> {
const result = await loadJson(
fetch, workspaceApiPath(observedData.workspaceId, "/orchestrator"), undefined,
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorLoading = false;
}
async function loadRepositories(): Promise<void> {
if (repositoriesLoading) return;
const observedData = data;
const generation = routeGeneration;
repositoriesLoading = true;
repositoryResponse = { data: null, error: null };
const result = await loadWorkspaceRepositoryList(fetch, observedData.workspaceId);
if (!isCurrent(observedData, generation)) return;
repositoryResponse = result;
repositoriesLoading = false;
}
function toggleCreate(): void {
creatingTicket = !creatingTicket;
if (creatingTicket && !repositoryResponse.data) void loadRepositories();
}
async function searchTickets(event?: SubmitEvent) {
event?.preventDefault();
if (searchBusy) return;
const observedData = data;
const generation = routeGeneration;
searchBusy = true;
navigationError = null;
try {
@@ -88,9 +140,11 @@
reset: false, refreshAll: true,
});
} catch (error) {
navigationError = error instanceof Error ? error.message : String(error);
if (isCurrent(observedData, generation)) {
navigationError = error instanceof Error ? error.message : String(error);
}
} finally {
searchBusy = false;
if (isCurrent(observedData, generation)) searchBusy = false;
}
}
@@ -106,21 +160,22 @@
async function loadMore(): Promise<void> {
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
const observedData = data;
const generation = routeGeneration;
const previous = response.data;
pageBusy = true;
response = { data: previous, error: null };
const result = await loadJson(
fetch,
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
workspaceApiPath(observedData.workspaceId, "/tickets/browse"),
{
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
},
parseTicketQueryResponse,
parseTicketBrowseQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
if (data !== observedData) return;
if (!isCurrent(observedData, generation)) return;
response = result.data
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
: { data: previous, error: result.error };
@@ -152,6 +207,8 @@
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
const observedData = data;
const generation = routeGeneration;
createBusy = true;
createError = null;
const request: NewTicket = {
@@ -166,31 +223,38 @@
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
workspaceApiPath(observedData.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (!isCurrent(observedData, generation)) return;
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
if (isCurrent(observedData, generation)) {
createError = error instanceof Error ? error.message : String(error);
}
} finally {
createBusy = false;
if (isCurrent(observedData, generation)) createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
if (orchestratorLoading || orchestratorStarting || orchestrator.data?.online) return;
const observedData = data;
const generation = routeGeneration;
orchestratorStarting = true;
orchestrator = await loadJson<WorkspaceOrchestratorStatus>(
const result = await loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(data.workspaceId, "/orchestrator"),
workspaceApiPath(observedData.workspaceId, "/orchestrator"),
{ method: "POST" },
parseBrowserWorkspaceOrchestratorResponse,
);
if (!isCurrent(observedData, generation)) return;
orchestrator = result;
orchestratorStarting = false;
}
@@ -214,13 +278,13 @@
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
onclick={toggleCreate}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<div class="orchestrator-status" data-online={orchestrator.data?.online} aria-busy={orchestratorLoading}>
<span class="orchestrator-status-dot"></span>
<div>
<strong>Orchestrator</strong>
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
<span>{orchestratorLoading ? "Checking…" : orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
</div>
{#if orchestrator.data && !orchestrator.data.online}
<button
@@ -240,8 +304,11 @@
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{#if repositoriesLoading}
<p role="status">Loading repositories…</p>
{:else if repositoryResponse.error}
<p class="workspace-callout is-error" role="alert">Repositories: {repositoryResponse.error}</p>
<button class="workspace-secondary-button" type="button" onclick={loadRepositories}>Retry repositories</button>
{/if}
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
@@ -0,0 +1,443 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { type ComponentProps, tick } from "svelte";
import { afterEach, expect, test, vi } from "vitest";
import type {
TicketBrowseQueryItem,
TicketBrowseQueryResponse,
} from "#lib/generated/ticket-api.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
import { compileTicketSearch } from "#lib/workspace/tickets/ticket-search.ts";
import TicketsPage from "./PageContent.svelte";
import { load } from "./+page.ts";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
type Data = ComponentProps<typeof TicketsPage>["data"];
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function ticket(id = "1", title = "First ticket"): TicketBrowseQueryItem {
return {
id,
resource_key: `T-${id}`,
title,
state: "open",
updated_at: null,
tags: [],
current_closure: null,
assignment_tracking: [],
};
}
function browse(
items = [ticket()],
cursor: string | null = null,
): TicketBrowseQueryResponse {
return {
items,
record_authority: "sqlite",
page: {
has_more: cursor !== null,
next_cursor: cursor,
limit: 50,
returned: items.length,
sort: "updated_desc",
source_limit: null,
source_truncated: false,
},
};
}
function data(
workspaceId = "workspace-a",
result = browse(),
search = "state:open",
): Data {
return {
workspaceId,
search,
queryRequest: compileTicketSearch(search, []),
tickets: { data: result, error: null },
} as Data;
}
function status(workspaceId = "workspace-a", online = true) {
return {
workspace_id: workspaceId,
online,
disposition: "observed",
worker: null,
diagnostics: [],
};
}
function repositories(workspaceId = "workspace-a") {
return {
workspace_id: workspaceId,
items: [],
source: "sqlite",
diagnostics: [],
};
}
function requests(routes: Record<string, () => Promise<Response>>) {
const request = vi.fn(
(input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const route = routes[String(input)];
if (!route) throw new Error(`Unexpected request: ${input}`);
return route();
},
);
vi.stubGlobal("fetch", request);
return request;
}
async function loadPage(
fetchFn: typeof fetch,
workspaceId = "workspace-a",
search = "state:open",
) {
const result = await load(
{
fetch: fetchFn,
params: { workspaceId },
url: new URL(
`https://example.test/w/${workspaceId}/tickets?q=${
encodeURIComponent(search)
}`,
),
} as Parameters<typeof load>[0],
).deferredPage;
if (!result.ok) throw new Error(result.failure.message);
return result.data as Data;
}
// Let response body decoding and Svelte effects finish before negative stale-result assertions.
async function flushResponses() {
await new Promise((resolve) => setTimeout(resolve, 0));
await tick();
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
test("actual loader posts browse only; rows render while orchestrator is unresolved and repositories stay lazy", async () => {
const rows = deferred<Response>();
const orchestrator = deferred<Response>();
const repos = deferred<Response>();
const request = requests({
"/api/w/workspace-a/tickets/browse": () => rows.promise,
"/api/w/workspace-a/orchestrator": () => orchestrator.promise,
"/api/w/workspace-a/repositories": () => repos.promise,
});
const pending = loadPage(request);
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
expect(request.mock.calls[0][1]?.method).toBe("POST");
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
compileTicketSearch("state:open", []),
);
rows.resolve(Response.json(browse()));
const view = render(TicketsPage, { data: await pending });
expect(await screen.findByText("First ticket")).toBeTruthy();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(
view.container.querySelector(".orchestrator-status")?.getAttribute(
"aria-busy",
),
).toBe("true");
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
expect(request.mock.calls.map(([path]) => path)).toEqual([
"/api/w/workspace-a/tickets/browse",
"/api/w/workspace-a/orchestrator",
]);
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(
request.mock.calls.filter(([path]) =>
String(path).endsWith("/repositories")
),
).toHaveLength(1);
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(screen.getByText("First ticket")).toBeTruthy();
repos.resolve(Response.json(repositories()));
orchestrator.resolve(Response.json(status()));
expect(await screen.findByText("Online")).toBeTruthy();
});
test("lazy repository HTTP failure is visible and retry enables targetless creation without reloading tickets", async () => {
const first = deferred<Response>();
const retry = deferred<Response>();
const repoRequest = vi.fn().mockReturnValueOnce(first.promise)
.mockReturnValueOnce(retry.promise);
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/repositories": repoRequest,
});
render(TicketsPage, { data: data() });
expect(repoRequest).not.toHaveBeenCalled();
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await fireEvent.input(screen.getByLabelText("Title"), {
target: { value: "New title" },
});
expect(
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
.disabled,
).toBe(true);
first.resolve(new Response("private failure body", { status: 503 }));
expect(await screen.findByRole("alert")).toHaveProperty(
"textContent",
"Repositories: Repository API request failed with HTTP 503",
);
expect(screen.queryByText(/private failure body/)).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Retry repositories" }),
);
expect(repoRequest).toHaveBeenCalledTimes(2);
expect(screen.getByText("Loading repositories…")).toBeTruthy();
retry.resolve(Response.json(repositories()));
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Create Ticket",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("First ticket")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
expect(repoRequest).toHaveBeenCalledTimes(2);
expect(request.mock.calls.some(([path]) => String(path).includes("/tickets")))
.toBe(false);
});
test("failed status is Unavailable, not Offline, and does not hide rows", async () => {
const orchestrator = deferred<Response>();
requests({ "/api/w/workspace-a/orchestrator": () => orchestrator.promise });
render(TicketsPage, { data: data() });
expect(screen.getByText("Checking…")).toBeTruthy();
orchestrator.reject(new Error("Controlled status failure"));
expect(await screen.findByText("Unavailable")).toBeTruthy();
expect(screen.getByText("Orchestrator status: Controlled status failure"))
.toBeTruthy();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.queryByRole("button", { name: "Start Orchestrator" }))
.toBeNull();
expect(screen.getByText("First ticket")).toBeTruthy();
});
test("late prior-Workspace status and repository responses cannot replace current state or finish its pending load", async () => {
const oldStatus = deferred<Response>();
const oldRepos = deferred<Response>();
const newStatus = deferred<Response>();
const newRepos = deferred<Response>();
requests({
"/api/w/workspace-a/orchestrator": () => oldStatus.promise,
"/api/w/workspace-a/repositories": () => oldRepos.promise,
"/api/w/workspace-b/orchestrator": () => newStatus.promise,
"/api/w/workspace-b/repositories": () => newRepos.promise,
});
const view = render(TicketsPage, { data: data() });
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await view.rerender({
data: data(
"workspace-b",
browse([ticket("2", "Current Workspace ticket")]),
),
});
expect(screen.queryByText("New Ticket")).toBeNull();
expect(screen.queryByText("First ticket")).toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Add Ticket" }));
await fireEvent.input(screen.getByLabelText("Title"), {
target: { value: "Current draft" },
});
oldStatus.resolve(Response.json(status("workspace-a", false)));
oldRepos.resolve(Response.json(repositories("workspace-a")));
await flushResponses();
expect(screen.getByText("Checking…")).toBeTruthy();
expect(screen.queryByText("Offline")).toBeNull();
expect(screen.getByText("Loading repositories…")).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Create Ticket" }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(screen.getByText("Current Workspace ticket")).toBeTruthy();
newStatus.resolve(Response.json(status("workspace-b")));
newRepos.resolve(Response.json(repositories("workspace-b")));
expect(await screen.findByText("Online")).toBeTruthy();
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Create Ticket",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect((screen.getByLabelText("Title") as HTMLInputElement).value).toBe(
"Current draft",
);
});
test("pagination posts browse with the query and cursor, preserves server order, and merges duplicate ids", async () => {
const next = deferred<Response>();
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/tickets/browse": () => next.promise,
});
const initial = data("workspace-a", browse([ticket()], "cursor-1"));
const view = render(TicketsPage, { data: initial });
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
const call = request.mock.calls.find(([path]) =>
String(path).endsWith("/tickets/browse")
)!;
expect(call[1]?.method).toBe("POST");
expect(JSON.parse(String(call[1]?.body))).toEqual({
...initial.queryRequest,
cursor: "cursor-1",
});
expect(screen.getByText("First ticket")).toBeTruthy();
next.resolve(
Response.json(
browse([
ticket("1", "Updated first ticket"),
ticket("2", "Second ticket"),
]),
),
);
expect(await screen.findByText("2 tickets loaded")).toBeTruthy();
expect(
[...view.container.querySelectorAll(".ticket-list-title")].map((node) =>
node.textContent
),
).toEqual(["Updated first ticket", "Second ticket"]);
expect(screen.queryByRole("button", { name: "Load more tickets" }))
.toBeNull();
});
for (const staleFailure of [false, true]) {
test(`prior-query pagination ${staleFailure ? "failure" : "success"} cannot overwrite current rows, cursor, or busy state`, async () => {
const oldPage = deferred<Response>();
const newPage = deferred<Response>();
const pageRequest = vi.fn().mockReturnValueOnce(oldPage.promise)
.mockReturnValueOnce(newPage.promise);
const request = requests({
"/api/w/workspace-a/orchestrator": async () => Response.json(status()),
"/api/w/workspace-a/tickets/browse": pageRequest,
});
const view = render(TicketsPage, {
data: data("workspace-a", browse([ticket()], "old-cursor")),
});
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
const current = data(
"workspace-a",
browse([ticket("2", "Current query ticket")], "new-cursor"),
"state:closed",
);
await view.rerender({ data: current });
await fireEvent.click(
screen.getByRole("button", { name: "Load more tickets" }),
);
oldPage.resolve(
staleFailure
? new Response("stale error", { status: 500 })
: Response.json(
browse([ticket("3", "Stale page ticket")], "stale-cursor"),
),
);
await flushResponses();
expect(screen.queryByText("Stale page ticket")).toBeNull();
expect(screen.queryByText("First ticket")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByText("Current query ticket")).toBeTruthy();
expect(screen.getByText("Loading…")).toBeTruthy();
expect(
screen.getByRole("region", { name: "Tickets" }).getAttribute("aria-busy"),
).toBe("true");
const calls = request.mock.calls.filter(([path]) =>
String(path).endsWith("/tickets/browse")
);
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
...current.queryRequest,
cursor: "new-cursor",
});
newPage.resolve(Response.json(browse([ticket("4", "Current next page")])));
expect(await screen.findByText("Current next page")).toBeTruthy();
expect(screen.getByText("2 tickets loaded")).toBeTruthy();
expect(screen.queryByText("Loading…")).toBeNull();
});
}
test("controlled loader-to-component timing isolates the former blocking Promise.all dependency gate", async () => {
// A local dependency-gating comparison, NOT a production latency benchmark.
// Both arms use the actual current browse loader/parser and real Svelte component.
// The baseline restores only the old Promise.all wait for status + repositories;
// it deliberately does not emulate the old enriched query's backend work.
const delays = { tickets: 20, secondary: 100 };
const measurements: Record<string, number> = {};
for (const blocking of [true, false]) {
const outstanding: Promise<Response>[] = [];
const delayed = (payload: unknown, ms: number) => {
const promise = new Promise<Response>((resolve) =>
setTimeout(() => resolve(Response.json(payload)), ms)
);
outstanding.push(promise);
return promise;
};
const request = requests({
"/api/w/workspace-a/tickets/browse": () =>
delayed(browse(), delays.tickets),
"/api/w/workspace-a/orchestrator": () =>
delayed(status(), delays.secondary),
"/api/w/workspace-a/repositories": () =>
delayed(repositories(), delays.secondary),
});
const start = performance.now();
const page = blocking
? (await Promise.all([
loadPage(request),
loadJson(
request,
workspaceApiPath("workspace-a", "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
loadWorkspaceRepositoryList(request, "workspace-a"),
]))[0]
: await loadPage(request);
render(TicketsPage, { data: page });
expect(await screen.findByText("First ticket")).toBeTruthy();
measurements[blocking ? "blockingBaselineMs" : "actualLoaderMs"] = Number(
(performance.now() - start).toFixed(2),
);
if (!blocking) {
expect(
request.mock.calls.some(([path]) =>
String(path).endsWith("/repositories")
),
).toBe(false);
}
cleanup();
await Promise.all(outstanding);
await flushResponses();
}
// No speed threshold: scheduler noise must not turn this into a flaky performance gate.
console.info(
"Controlled mocked loader-to-component timing (not production):",
JSON.stringify({ delaysMs: delays, ...measurements }),
);
});