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:
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
||||
})
|
||||
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_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> {
|
||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
"required O- key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
let projected = serde_json::json!({
|
||||
@@ -712,7 +710,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
@@ -764,7 +762,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
@@ -833,7 +831,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_output_rejects_noncanonical_human_keys() {
|
||||
fn objective_output_rejects_noncanonical_keys() {
|
||||
let response = ObjectiveDetail {
|
||||
resource_key: "O-internal".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> {
|
||||
let item = object(value, "Ticket query item")?;
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: human_ref(item, "resource_key", "T-")?,
|
||||
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
.transpose()?,
|
||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "O-"))
|
||||
.map(|key| validate_resource_ref(key, "O-"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relation_count: usize_field(item, "relation_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<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: human_ref(root, "resource_key", "T-")?,
|
||||
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
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 linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "T-"))
|
||||
.map(|key| validate_resource_ref(key, "T-"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelObjectiveQueryItem {
|
||||
objective: human_ref(item, "resource_key", "O-")?,
|
||||
objective: resource_ref(item, "resource_key", "O-")?,
|
||||
title: string_field(item, "title")?,
|
||||
summary: optional_string(item, "snippet")?,
|
||||
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> {
|
||||
let root = object(&value, "Objective detail response")?;
|
||||
Ok(ModelObjectiveDetail {
|
||||
objective: human_ref(root, "resource_key", "O-")?,
|
||||
objective: resource_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
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> {
|
||||
let worker = object(value, "Worker summary")?;
|
||||
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")?,
|
||||
};
|
||||
Ok(ModelRelation {
|
||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
||||
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
@@ -449,7 +449,7 @@ fn project_relation(
|
||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||
let blocker = object(value, "Ticket blocker")?;
|
||||
Ok(ModelBlocker {
|
||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
kind: string_field(blocker, "relation_kind")?,
|
||||
state: optional_string(blocker, "blocking_state")?,
|
||||
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> {
|
||||
let summary = object(value, "Objective summary")?;
|
||||
Ok(ModelObjectiveSummary {
|
||||
objective: human_ref(summary, "resource_key", "O-")?,
|
||||
objective: resource_ref(summary, "resource_key", "O-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
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> {
|
||||
let summary = object(value, "Ticket summary")?;
|
||||
Ok(ModelTicketSummary {
|
||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
||||
ticket: resource_ref(summary, "resource_key", "T-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
@@ -491,9 +491,7 @@ fn project_assignment(
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| {
|
||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
||||
})?,
|
||||
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
"user" => "user".to_string(),
|
||||
other => format!("source:{other}"),
|
||||
@@ -645,23 +643,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
|
||||
.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
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
||||
validate_human_ref(value, prefix)
|
||||
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
|
||||
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| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
if valid {
|
||||
Ok(value)
|
||||
} 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;
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_human_resource_references() {
|
||||
fn objective_projection_exposes_only_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
"id": "00001M10HW6BV",
|
||||
"resource_key": "O-543",
|
||||
@@ -779,9 +777,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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-")] {
|
||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
||||
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,9 +306,11 @@ struct BackendTicketService {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
let ticket = self.backend.show(ticket_ref.into())?;
|
||||
struct WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend,
|
||||
}
|
||||
|
||||
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||
let resource_key = ticket
|
||||
.meta
|
||||
.resource_key
|
||||
@@ -320,6 +322,17 @@ impl TicketService for BackendTicketService {
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
})
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketService for WorkspaceTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
|
||||
let Some(backend) = self.tool_backend(context) else {
|
||||
return Ok(());
|
||||
};
|
||||
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
||||
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
||||
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
||||
}),
|
||||
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
||||
backend: backend.clone(),
|
||||
});
|
||||
}),
|
||||
};
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
|
||||
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 {
|
||||
let value = match id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
|
||||
endpoint: String,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> 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 {
|
||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
||||
})?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
serde_json::from_str(&response.body)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
||||
})
|
||||
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
.is_some_and(is_canonical_ticket_resource_key)
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"required Ticket human key is unavailable".to_string(),
|
||||
"required Ticket key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
@@ -1861,7 +1916,7 @@ provider = "github"
|
||||
}
|
||||
|
||||
#[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 addr = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
@@ -2000,6 +2055,46 @@ provider = "github"
|
||||
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]
|
||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||
|
||||
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
|
||||
.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)]
|
||||
struct MergeRequestListHttpQuery {
|
||||
state: Option<String>,
|
||||
@@ -8217,6 +8247,11 @@ async fn spawn_known_worker(
|
||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
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() {
|
||||
"assigned"
|
||||
} else {
|
||||
@@ -20220,6 +20255,25 @@ mod tests {
|
||||
.unwrap(),
|
||||
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 {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
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:
|
||||
|
||||
- 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;
|
||||
- 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;
|
||||
|
||||
@@ -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", () => {
|
||||
assert(
|
||||
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(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
|
||||
Reference in New Issue
Block a user