Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot

# Conflicts:
#	web/workspace/src/lib/workspace/console/model.ts
This commit is contained in:
2026-08-30 00:42:29 +09:00
8 changed files with 348 additions and 55 deletions
+1 -1
View File
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
match ticket.meta.resource_key { match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key), Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!( Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket human key is unavailable" "{tool_name} failed: required Ticket key is unavailable"
))), ))),
None => Ok(ticket.meta.id), None => Ok(ticket.meta.id),
} }
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-")) .filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok_or_else(|| { .ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
})
} }
fn objective_url(&self, id: &str) -> String { fn objective_url(&self, id: &str) -> String {
@@ -295,7 +293,7 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> { fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") { if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed( return Err(ToolError::ExecutionFailed(
"required O- human key is unavailable".to_string(), "required O- key is unavailable".to_string(),
)); ));
} }
let projected = serde_json::json!({ let projected = serde_json::json!({
@@ -712,7 +710,7 @@ mod tests {
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_human_key() { async fn objective_show_summary_uses_projected_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || { let server = thread::spawn(move || {
@@ -764,7 +762,7 @@ mod tests {
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() { async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || { let server = thread::spawn(move || {
@@ -833,7 +831,7 @@ mod tests {
} }
#[test] #[test]
fn objective_output_rejects_noncanonical_human_keys() { fn objective_output_rejects_noncanonical_keys() {
let response = ObjectiveDetail { let response = ObjectiveDetail {
resource_key: "O-internal".to_string(), resource_key: "O-internal".to_string(),
title: "Objective".to_string(), title: "Objective".to_string(),
@@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryRespo
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> { fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?; let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem { Ok(ModelTicketQueryItem {
ticket: human_ref(item, "resource_key", "T-")?, ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?, title: string_field(item, "title")?,
state: string_field(item, "state")?, state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?, readiness: optional_string(item, "readiness")?,
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
.transpose()?, .transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")? linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter() .into_iter()
.map(|key| validate_human_ref(key, "O-")) .map(|key| validate_resource_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?, relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?, blocker_count: usize_field(item, "blocker_count")?,
@@ -273,7 +273,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail { Ok(ModelTicketDetail {
ticket: human_ref(root, "resource_key", "T-")?, ticket: resource_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?, title: string_field(root, "title")?,
body: string_field(root, "body")?, body: string_field(root, "body")?,
state: string_field(root, "state")?, state: string_field(root, "state")?,
@@ -332,10 +332,10 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
let item = object(value, "Objective query item")?; let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")? let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter() .into_iter()
.map(|key| validate_human_ref(key, "T-")) .map(|key| validate_resource_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem { Ok(ModelObjectiveQueryItem {
objective: human_ref(item, "resource_key", "O-")?, objective: resource_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?, title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?, summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?, state: string_field(item, "state")?,
@@ -349,7 +349,7 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> { pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?; let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail { Ok(ModelObjectiveDetail {
objective: human_ref(root, "resource_key", "O-")?, objective: resource_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?, title: string_field(root, "title")?,
body: string_field(root, "body")?, body: string_field(root, "body")?,
state: string_field(root, "state")?, state: string_field(root, "state")?,
@@ -373,7 +373,7 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> { fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?; let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary { Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?, worker: resource_ref(worker, "worker_resource_key", "W-")?,
}) })
} }
@@ -439,7 +439,7 @@ fn project_relation(
None => optional_string(relation, "at")?, None => optional_string(relation, "at")?,
}; };
Ok(ModelRelation { Ok(ModelRelation {
ticket: human_ref(relation, ticket_key, "T-")?, ticket: resource_ref(relation, ticket_key, "T-")?,
kind, kind,
note, note,
created_at, created_at,
@@ -449,7 +449,7 @@ fn project_relation(
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> { fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?; let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker { Ok(ModelBlocker {
ticket: human_ref(blocker, "blocking_resource_key", "T-")?, ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?, kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?, state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?, resolved: bool_field(blocker, "resolved")?,
@@ -466,7 +466,7 @@ fn project_notice(value: &Value) -> Result<ModelNotice, String> {
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> { fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?; let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary { Ok(ModelObjectiveSummary {
objective: human_ref(summary, "resource_key", "O-")?, objective: resource_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?, title: string_field(summary, "title")?,
state: string_field(summary, "state")?, state: string_field(summary, "state")?,
}) })
@@ -475,7 +475,7 @@ fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, Str
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> { fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?; let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary { Ok(ModelTicketSummary {
ticket: human_ref(summary, "resource_key", "T-")?, ticket: resource_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?, title: string_field(summary, "title")?,
state: string_field(summary, "state")?, state: string_field(summary, "state")?,
}) })
@@ -491,9 +491,7 @@ fn project_assignment(
let principal = match kind.as_str() { let principal = match kind.as_str() {
"worker" => current_coder "worker" => current_coder
.map(|coder| coder.worker.clone()) .map(|coder| coder.worker.clone())
.ok_or_else(|| { .ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
"Worker assignment is missing a Workspace human key projection".to_string()
})?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?), "workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(), "user" => "user".to_string(),
other => format!("source:{other}"), other => format!("source:{other}"),
@@ -645,23 +643,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
.collect() .collect()
} }
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> { fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object let value = object
.get(key) .get(key)
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?; .ok_or_else(|| format!("required {prefix} key is unavailable"))?;
validate_human_ref(value, prefix) validate_resource_ref(value, prefix)
} }
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> { fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| { let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit()) !sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
}); });
if valid { if valid {
Ok(value) Ok(value)
} else { } else {
Err(format!("required {prefix} human key is unavailable")) Err(format!("required {prefix} key is unavailable"))
} }
} }
@@ -671,7 +669,7 @@ mod tests {
use serde_json::json; use serde_json::json;
#[test] #[test]
fn objective_projection_exposes_only_human_resource_references() { fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({ let projected = project_objective_detail(json!({
"id": "00001M10HW6BV", "id": "00001M10HW6BV",
"resource_key": "O-543", "resource_key": "O-543",
@@ -779,9 +777,9 @@ mod tests {
} }
#[test] #[test]
fn human_resource_projection_rejects_noncanonical_keys() { fn resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] { for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_human_ref(key.to_string(), prefix).is_err()); assert!(validate_resource_ref(key.to_string(), prefix).is_err());
} }
} }
+119 -24
View File
@@ -306,19 +306,32 @@ struct BackendTicketService {
backend: TicketToolBackend, backend: TicketToolBackend,
} }
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
}
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
let resource_key = ticket
.meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
}
impl TicketService for BackendTicketService { impl TicketService for BackendTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> { fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
let ticket = self.backend.show(ticket_ref.into())?; ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
let resource_key = ticket }
.meta }
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key)) impl TicketService for WorkspaceTicketService {
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?; fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(TicketHandoff { ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
} }
} }
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else { let Some(backend) = self.tool_backend(context) else {
return Ok(()); return Ok(());
}; };
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService { let ticket_service: Arc<dyn TicketService> = match &self.backend {
backend: backend.clone(), TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
}); backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
context.services().provide( context.services().provide(
ServiceDeclaration::new( ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID), ServiceId::builtin(TICKET_SERVICE_ID),
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
Self::invoke_client(client, workspace_id, operation) Self::invoke_client(client, workspace_id, operation)
} }
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
let client = self.client.clone();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
let request = move || {
Self::request_unprojected(
client,
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
None,
)
};
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(request).join().map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
request()
}
fn ticket_path(id: &TicketIdOrSlug) -> String { fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id { let value = match id {
TicketIdOrSlug::Id(value) TicketIdOrSlug::Id(value)
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
endpoint: String, endpoint: String,
body: Option<serde_json::Value>, body: Option<serde_json::Value>,
) -> TicketResult<T> { ) -> TicketResult<T> {
let mut value = Self::request_value(client, method, endpoint, body)?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unprojected<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let value = Self::request_value(client, method, endpoint, body)?;
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_value(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<Value> {
let request = match body { let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()), Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint), None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
response.status response.status
))); )));
} }
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| { serde_json::from_str(&response.body)
TicketError::Conflict(format!("decode ticket REST response: {error}"))
})?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}"))) .map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
} }
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
.and_then(Value::as_str) .and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key)) .filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.ok_or_else(|| { .ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
TicketError::Conflict("required Ticket human key is unavailable".to_string())
})
} }
fn request_unit( fn request_unit(
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
.is_some_and(is_canonical_ticket_resource_key) .is_some_and(is_canonical_ticket_resource_key)
{ {
return Err(TicketError::Conflict( return Err(TicketError::Conflict(
"required Ticket human key is unavailable".to_string(), "required Ticket key is unavailable".to_string(),
)); ));
} }
Ok(TicketBackendOperationResult::Ticket(ticket)) Ok(TicketBackendOperationResult::Ticket(ticket))
@@ -1861,7 +1916,7 @@ provider = "github"
} }
#[test] #[test]
fn workspace_http_backend_records_relation_with_authoritative_human_keys() { fn workspace_http_backend_records_relation_with_authoritative_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || { let server = thread::spawn(move || {
@@ -2000,6 +2055,46 @@ provider = "github"
assert_eq!(removed.target, "T-2"); assert_eq!(removed.target, "T-2");
} }
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let response_body = serde_json::to_string(&ticket).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
});
let service = WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
};
let handoff = service.ticket_handoff("T-548").unwrap();
server.join().unwrap();
assert_eq!(handoff.id, created.id);
assert_eq!(handoff.resource_key, "T-548");
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
}
#[test] #[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() { fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482")); assert!(is_canonical_ticket_resource_key("T-482"));
+54
View File
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into()) .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
} }
fn resolve_workspace_ticket_identity(
api: &WorkspaceApi,
workspace_id: &str,
reference: &str,
) -> ApiResult<String> {
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
let ticket = browser_ticket_backend(api)?
.show(ticket_id.clone().into())
.map_err(Error::from)?;
if ticket.meta.id != ticket_id {
return Err(Error::InvalidInput(
"resolved Ticket identity does not match Ticket authority".to_string(),
)
.into());
}
Ok(ticket_id)
}
fn resolve_workspace_worker_ticket_assignment(
api: &WorkspaceApi,
workspace_id: &str,
assignment: &mut Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
) -> ApiResult<()> {
if let Some(assignment) = assignment {
assignment.ticket_id =
resolve_workspace_ticket_identity(api, workspace_id, &assignment.ticket_id)?;
}
Ok(())
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct MergeRequestListHttpQuery { struct MergeRequestListHttpQuery {
state: Option<String>, state: Option<String>,
@@ -8217,6 +8247,11 @@ async fn spawn_known_worker(
) -> ApiResult<Json<BrowserCreateWorkerResponse>> { ) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
resolve_workspace_worker_ticket_assignment(
&api,
&path.workspace_id,
&mut request.ticket_assignment,
)?;
let relation = if request.ticket_assignment.is_some() { let relation = if request.ticket_assignment.is_some() {
"assigned" "assigned"
} else { } else {
@@ -20220,6 +20255,25 @@ mod tests {
.unwrap(), .unwrap(),
ticket_id ticket_id
); );
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_resource_key)
.unwrap(),
ticket_id
);
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_id).unwrap(),
ticket_id
);
let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest {
ticket_id: ticket_resource_key.clone(),
operation_id: "ticket-key-assignment".to_string(),
});
resolve_workspace_worker_ticket_assignment(&api, TEST_WORKSPACE_ID, &mut assignment)
.unwrap();
assert_eq!(assignment.unwrap().ticket_id, ticket_id);
let missing =
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, "T-999999").unwrap_err();
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
let path = || ScopedRecordPath { let path = || ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(), id: ticket_id.clone(),
@@ -19,7 +19,7 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; - rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; - adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error; - materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist; - validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
@@ -238,6 +238,108 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
); );
}); });
Deno.test("reload snapshot projects provenance-annotated history entries", () => {
const metadata = {
entry_id: "history-entry-1",
origin: {
kind: "model_output",
worker: {
workspace_id: "workspace-secret",
runtime_id: "runtime-secret",
worker_id: "worker-secret",
},
},
};
const annotated = (item: unknown) => ({ item, metadata });
const projection = projectConsole([{
eventId: "annotated-reload",
event: snapshotEvent("/repo", [
{
kind: "annotated_segment_start",
ts: 1,
session_id: "session-1",
system_prompt: null,
config: {},
history: [annotated({
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "older committed reply" }],
})],
},
{
kind: "annotated_user_input",
ts: 2,
segments: [{ kind: "text", content: "latest user message" }],
history: [annotated({
kind: "message",
role: "user",
content: [{ kind: "text", text: "latest user message" }],
})],
},
{
kind: "annotated_assistant_item",
ts: 3,
entry: annotated({
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "latest committed reply" }],
}),
},
{
kind: "annotated_assistant_item",
ts: 4,
entry: annotated({
kind: "tool_call",
call_id: "annotated-call",
name: "Read",
arguments: '{"file_path":"/repo/a.md"}',
}),
},
{
kind: "annotated_tool_result",
ts: 5,
entry: annotated({
kind: "tool_result",
call_id: "annotated-call",
summary: "Read 1 line from /repo/a.md",
content: "1→content",
is_error: false,
}),
},
{
kind: "annotated_system_item",
ts: 6,
entry: annotated({
kind: "notification",
message: "Worker completed",
}),
},
]),
}]);
assertEquals(
projection.lines.map((line) =>
`${line.kind}:${line.toolCallLabel ?? line.body}`
),
[
"assistant:older committed reply",
"user:latest user message",
"assistant:latest committed reply",
"tool:Read(1 file)",
"system:Worker completed",
],
);
const visible = JSON.stringify(projection.lines);
assert(
!visible.includes("workspace-secret"),
"history metadata must not enter Console rows",
);
assert(
!visible.includes("runtime-secret"),
"history origin must remain non-visible metadata",
);
});
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
assert( assert(
workerConsoleHref({ workerConsoleHref({
@@ -2072,6 +2072,52 @@ function compactMessageForState(
} }
} }
function applyLoggedUserInput(
projection: ConsoleProjection,
eventId: string,
entry: Record<string, unknown>,
): void {
let body = segmentsToText(arrayField(entry, "segments") as Segment[]);
if (!body && stringField(entry, "kind") === "annotated_user_input") {
body = loggedUserText(arrayField(entry, "history"));
}
projection.lines.push(line(eventId, "user", "User", body));
}
function loggedUserText(history: unknown[]): string {
for (const historyEntry of history) {
if (!isRecord(historyEntry) || !isRecord(historyEntry["item"])) continue;
const item = historyEntry["item"];
if (
stringField(item, "kind") !== "message" ||
stringField(item, "role") !== "user"
) {
continue;
}
return loggedContentText(arrayField(item, "content"));
}
return "";
}
function applyLoggedHistoryEntry(
projection: ConsoleProjection,
eventId: string,
historyEntry: unknown,
): void {
if (!isRecord(historyEntry)) return;
applyLoggedItem(projection, eventId, historyEntry["item"]);
}
function applyLoggedSystemEntry(
projection: ConsoleProjection,
eventId: string,
historyEntry: unknown,
): void {
if (!isRecord(historyEntry)) return;
projection.lines.push(systemItemLine(eventId, historyEntry["item"]));
applyTaskSystemItem(projection, historyEntry["item"]);
}
function applyLoggedItem( function applyLoggedItem(
projection: ConsoleProjection, projection: ConsoleProjection,
eventId: string, eventId: string,