Merge branch 'develop' into hare/develop

This commit is contained in:
2026-10-08 20:46:36 +09:00
77 changed files with 11940 additions and 3109 deletions
+5 -8
View File
@@ -937,7 +937,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
///
/// The callback is invoked with a short human-readable message
/// whenever the Engine encounters a condition that should be
/// surfaced to a human (e.g. tool output byte-cap truncation).
/// surfaced to a human (e.g. a provider-rejected image). Tool output
/// byte-cap truncation is reported in the tool result, not this channel.
/// This channel is separate from `tracing::warn!`, which remains
/// in place for developer logs.
pub fn on_warning(&mut self, callback: impl Fn(&str) + Send + Sync + 'static) {
@@ -2379,13 +2380,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
limit_bytes = limit,
"Tool output exceeded byte limit and was truncated"
);
self.emit_warning(&format!(
"tool `{}` output truncated from {} to {} bytes (limit {})",
tool_call.name,
before,
content.len(),
limit
));
// Truncation is a tool-result constraint, not an actionable
// engine warning. The content marker tells the model to refine
// its query; keep diagnostics in tracing, not user alerts.
}
}
+176
View File
@@ -16,6 +16,182 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use common::MockLlmClient;
/// A per-run tracing sink: no global subscriber or output-body logging.
#[derive(Clone)]
struct DiagnosticLog(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for DiagnosticLog {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn output_limits_keep_partial_results_without_warning_callbacks() {
use agen::tool::{Attachment, ImageAttachment, ToolOutputLimits, ToolResult};
use agen::{History, Item, ToolResultDisposition};
use tracing::instrument::WithSubscriber;
let limit = 256;
let cases = [
("below", "small result".to_string(), limit),
("exact", "x".repeat(limit), limit),
("Grep", "matched line\n".repeat(200), limit),
("Read", "あいうえお".repeat(200), 120),
];
let mut events = Vec::new();
for (index, name) in cases.iter().map(|c| c.0).chain(["erroring"]).enumerate() {
events.extend([
Event::tool_use_start(index, name, name),
Event::tool_input_delta(index, "{}"),
Event::tool_use_stop(index),
]);
}
events.push(Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
}));
let client = MockLlmClient::with_responses(vec![
events,
vec![Event::Status(ClientStatusEvent {
status: ResponseStatus::Completed,
})],
]);
let probe = client.clone();
let mut engine = Engine::new(client);
engine.set_tool_call_dispatch_mode(agen::ToolCallDispatchMode::AfterResponse);
engine.set_tool_output_limits(Some(ToolOutputLimits {
default_max_bytes: limit,
per_tool: [("Read".to_string(), 120)].into(),
}));
let attachments = vec![Attachment::Image(ImageAttachment::new(
"image/png",
b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec(),
))];
for (name, content, _) in &cases {
engine.register_tool(fixed_tool(
name,
ToolOutput {
summary: format!("result from {name}"),
content: Some(content.clone()),
attachments: attachments.clone(),
},
));
}
engine.register_tool(erroring_tool("erroring", "execution failed"));
let warnings = Arc::new(Mutex::new(Vec::new()));
let sink = warnings.clone();
engine.on_warning(move |warning| sink.lock().unwrap().push(warning.to_owned()));
let published = Arc::new(Mutex::new(Vec::<ToolResult>::new()));
let sink = published.clone();
engine.on_tool_result(move |result| sink.lock().unwrap().push(result.clone()));
let committed = Arc::new(Mutex::new(Vec::new()));
let sink = committed.clone();
engine.on_history_append(move |item| {
if matches!(item, Item::ToolResult { .. }) {
sink.lock().unwrap().push(item.clone());
}
Ok(())
});
let log = DiagnosticLog(Arc::new(Mutex::new(Vec::new())));
let writer = log.clone();
let subscriber = tracing_subscriber::fmt()
.without_time()
.with_ansi(false)
.with_max_level(tracing::Level::WARN)
.with_writer(move || writer.clone())
.finish();
let mut history = History::new();
let run = engine
.run(&mut history, "run tools")
.with_subscriber(subscriber)
.await;
assert!(matches!(run.result, agen::EngineRunExit::Finished));
assert!(warnings.lock().unwrap().is_empty());
let requests = probe.requests();
assert_eq!(requests.len(), 2);
let saved: Vec<_> = history
.iter()
.filter_map(|entry| matches!(entry.item, Item::ToolResult { .. }).then_some(&entry.item))
.collect();
assert_eq!(saved.len(), cases.len() + 1);
let committed = committed.lock().unwrap();
let published = published.lock().unwrap();
let logs = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
for item in saved {
let Item::ToolResult {
call_id,
summary,
content,
disposition,
is_error,
attachments: saved_attachments,
..
} = item
else {
unreachable!()
};
assert!(committed.iter().any(|candidate| candidate == item));
assert!(requests[1].items.iter().any(|candidate| candidate == item));
let result = published
.iter()
.find(|result| result.tool_use_id == *call_id)
.unwrap();
assert_eq!(&result.summary, summary);
assert_eq!(&result.content, content);
assert_eq!(&result.disposition, disposition);
assert_eq!(&result.is_error, is_error);
assert_eq!(&result.attachments, saved_attachments);
if call_id == "erroring" {
assert_eq!(*disposition, ToolResultDisposition::Error);
assert!(*is_error);
assert!(content.is_none());
continue;
}
assert_eq!(*disposition, ToolResultDisposition::Success);
assert!(!*is_error);
assert_eq!(*summary, format!("result from {call_id}"));
assert_eq!(saved_attachments, &attachments);
let (_, original, cap) = cases.iter().find(|c| c.0 == call_id).unwrap();
let content = content.as_ref().unwrap();
assert!(content.len() <= *cap);
if original.len() <= *cap {
assert_eq!(content, original);
} else {
let (body, marker) = content.split_once("\n\n[truncated: ").unwrap();
assert!(original.starts_with(body));
assert_eq!(
marker,
format!(
"{} bytes dropped, refine your query]",
original.len() - body.len()
)
);
let diagnostic = logs
.lines()
.find(|line| line.contains(&format!("tool={call_id}")))
.unwrap();
assert!(diagnostic.contains(&format!("before_bytes={}", original.len())));
assert!(diagnostic.contains(&format!("after_bytes={}", content.len())));
assert!(diagnostic.contains(&format!("limit_bytes={cap}")));
assert!(!diagnostic.contains(body));
}
}
assert_eq!(
logs.lines()
.filter(|line| line.contains("Tool output exceeded byte limit"))
.count(),
2
);
}
#[derive(Clone)]
struct FailOnceClient {
calls: Arc<AtomicUsize>,
+2 -2
View File
@@ -434,11 +434,11 @@ mod tests {
let request = CreateBackendWorkspaceRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository: Some(CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
default_ref: Some("develop".to_string()),
},
}),
};
let retry = request.clone();
+184 -4
View File
@@ -155,14 +155,40 @@ impl BackendWorkspaceProductClient {
})
}
fn decision_from_state_change(
&self,
id: &TicketIdOrSlug,
change: &TicketStateChange,
) -> Result<ticket::TicketStateUpdate, BackendWorkspaceClientError> {
let current = self.show_ticket(id)?;
let parse = |state: &str| {
ticket::TicketWorkflowState::parse(state).ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}"))
})
};
Ok(ticket::TicketStateUpdate {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current),
expected_state: parse(&change.from)?,
state: parse(&change.to)?,
reason: if change.body.as_str().is_empty() {
change.reason.clone()
} else {
format!("{}\n\n{}", change.reason, change.body.as_str())
},
references: change.references.clone(),
author: change.author.clone(),
})
}
pub fn set_ticket_workflow_state(
&self,
id: &TicketIdOrSlug,
change: &TicketStateChange,
) -> Result<(), BackendWorkspaceClientError> {
let change = self.decision_from_state_change(id, change)?;
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(id);
let change = change.clone();
self.generated(move |client| async move {
client
.ticket_workflow_state_set(
@@ -179,15 +205,23 @@ impl BackendWorkspaceProductClient {
id: &TicketIdOrSlug,
resolution: &MarkdownText,
) -> Result<(), BackendWorkspaceClientError> {
let current = self.show_ticket(id)?;
let request = ticket::TicketCompletion {
operation_key: uuid::Uuid::now_v7().to_string(),
expected_item_revision: ticket::ticket_item_revision(&current),
expected_state: current.meta.workflow_state,
reason: resolution.as_str().to_string(),
references: Vec::new(),
author: None,
};
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(id);
let resolution = resolution.clone();
self.generated(move |client| async move {
client
.ticket_close_record(
workspace_id,
id,
server_api::TicketCloseRecordRequest(resolution),
server_api::TicketCloseRecordRequest(request),
)
.await
})
@@ -633,6 +667,9 @@ impl TicketBackend for BackendWorkspaceProductClient {
id: TicketIdOrSlug,
change: TicketStateChange,
) -> ticket::Result<()> {
let change = self
.decision_from_state_change(&id, &change)
.map_err(ticket_client_error)?;
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
@@ -672,6 +709,14 @@ impl TicketBackend for BackendWorkspaceProductClient {
field: &str,
change: TicketStateChange,
) -> ticket::Result<()> {
if field != "state" {
return Err(TicketError::Conflict(
"unsupported Ticket state field".into(),
));
}
let change = self
.decision_from_state_change(&id, &change)
.map_err(ticket_client_error)?;
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
let field = field.to_string();
@@ -697,6 +742,26 @@ impl TicketBackend for BackendWorkspaceProductClient {
.map_err(ticket_client_error)
}
fn update_state(
&self,
ticket: &str,
request: ticket::TicketStateUpdate,
) -> ticket::Result<Ticket> {
let workspace_id = self.workspace_id.clone();
let id = ticket.to_owned();
self.generated(move |client| async move {
client
.ticket_state_update(
workspace_id,
id,
server_api::TicketStateUpdateRequest(request),
)
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
@@ -903,8 +968,12 @@ mod tests {
}
fn response_sequence_server(
responses: Vec<(&'static str, &'static str)>,
responses: Vec<(&str, &str)>,
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
let responses = responses
.into_iter()
.map(|(status, body)| (status.to_owned(), body.to_owned()))
.collect::<Vec<_>>();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (sender, receiver) = mpsc::channel();
@@ -927,6 +996,117 @@ mod tests {
(format!("http://{address}"), receiver, handle)
}
#[test]
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() {
let temp = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
.unwrap();
let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap();
let snapshot = backend.show(reference.id.clone().into()).unwrap();
let body = serde_json::to_string(&snapshot).unwrap();
let (base_url, requests, handle) =
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
let client =
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
.unwrap();
client
.set_ticket_workflow_state(
&reference.id.clone().into(),
&TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"),
)
.unwrap();
assert!(requests.recv().unwrap().starts_with("GET "));
let request = requests.recv().unwrap();
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(
body["expected_item_revision"],
ticket::ticket_item_revision(&snapshot)
);
assert_eq!(body["expected_state"], "ready");
assert_eq!(body["state"], "done");
assert!(!body["operation_key"].as_str().unwrap().is_empty());
handle.join().unwrap();
}
#[test]
fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() {
let temp = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
.unwrap();
let reference = backend
.create(NewTicket::new("Close adapter snapshot"))
.unwrap();
let snapshot = backend.show(reference.id.clone().into()).unwrap();
let body = serde_json::to_string(&snapshot).unwrap();
let (base_url, requests, handle) =
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
let client =
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
.unwrap();
client
.close_ticket(
&reference.id.into(),
&MarkdownText::new("The request was withdrawn"),
)
.unwrap();
assert!(requests.recv().unwrap().starts_with("GET "));
let request = requests.recv().unwrap();
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(
body["expected_item_revision"],
ticket::ticket_item_revision(&snapshot)
);
assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "The request was withdrawn");
assert!(!body["operation_key"].as_str().unwrap().is_empty());
handle.join().unwrap();
}
#[test]
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
let (base_url, request, handle) = one_response_server(
"409 Conflict",
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#,
);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-token",
)
.unwrap();
let result = TicketBackend::complete(
&client,
"T-718",
ticket::TicketCompletion {
operation_key: "research-done".into(),
expected_item_revision: "ticket:1".into(),
expected_state: ticket::TicketWorkflowState::Planning,
reason: "Research findings recorded".into(),
references: Vec::new(),
author: None,
},
);
assert!(result.is_err());
let request = request.recv().unwrap();
assert!(
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
"{request}"
);
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["state"], "done");
assert_eq!(body["operation_key"], "research-done");
assert_eq!(body["expected_item_revision"], "ticket:1");
assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "Research findings recorded");
assert!(body.get("merge_request_ids").is_none());
handle.join().unwrap();
}
#[test]
fn memory_document_uses_shared_workspace_scoped_response() {
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
+2 -2
View File
@@ -8,11 +8,11 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
let request = CreateBackendWorkspaceRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Alpha".to_string(),
repository: CreateBackendWorkspaceRepository {
repository: Some(CreateBackendWorkspaceRepository {
repository_key: "main".to_string(),
uri: "/srv/repos/alpha".to_string(),
default_ref: Some("develop".to_string()),
},
}),
};
assert_eq!(request.clone(), request);
+237 -288
View File
@@ -236,10 +236,244 @@ pub struct MergeRequest {
#[serde(default)]
pub thread: Vec<MergeRequestThreadEvent>,
}
/// Bounded failures from pure evaluation of persisted review and merge evidence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeRequestEvidenceError {
NotMerged,
MergeResultMissing,
ApprovalMissing,
ApprovalRevoked,
ApprovalNotApproved,
ApprovalSourceMismatch,
ApprovalTimelineMismatch,
ReviewRequestMissing,
ReviewRequestMismatch,
ApprovalNotEffective,
ItemRevisionMismatch,
SourceSnapshotMismatch,
RequirementApprovalMissing,
}
impl MergeRequestEvidenceError {
/// Stable machine-readable diagnostic code, separate from the human message.
pub fn code(self) -> &'static str {
match self {
Self::NotMerged => "not_merged",
Self::MergeResultMissing => "merge_result_missing",
Self::ApprovalMissing => "approval_missing",
Self::ApprovalRevoked => "approval_revoked",
Self::ApprovalNotApproved => "approval_not_approved",
Self::ApprovalSourceMismatch => "approval_source_mismatch",
Self::ApprovalTimelineMismatch => "approval_timeline_mismatch",
Self::ReviewRequestMissing => "review_request_missing",
Self::ReviewRequestMismatch => "review_request_mismatch",
Self::ApprovalNotEffective => "approval_not_effective",
Self::ItemRevisionMismatch => "item_revision_mismatch",
Self::SourceSnapshotMismatch => "source_snapshot_mismatch",
Self::RequirementApprovalMissing => "requirement_approval_missing",
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::NotMerged => "Merge Request has no merged result",
Self::MergeResultMissing => "merged Merge Request has no MergeResult",
Self::ApprovalMissing => "integration approval is missing",
Self::ApprovalRevoked => "Reviewer approval is revoked",
Self::ApprovalNotApproved => "Reviewer decision is not an approval",
Self::ApprovalSourceMismatch => "Reviewer approval does not match the exact source ref",
Self::ApprovalTimelineMismatch => {
"integration approval does not predate the merged result"
}
Self::ReviewRequestMissing => "linked ReviewRequested evidence is missing",
Self::ReviewRequestMismatch => {
"Reviewer approval does not match linked ReviewRequested evidence"
}
Self::ApprovalNotEffective => "Reviewer approval is not the effective review",
Self::ItemRevisionMismatch => {
"Reviewer approval does not attest the current Ticket revision"
}
Self::SourceSnapshotMismatch => {
"Reviewer approval does not attest the exact linked source set"
}
Self::RequirementApprovalMissing => {
"no effective Reviewer approval attests the current Ticket revision and exact linked source set"
}
}
}
}
impl std::fmt::Display for MergeRequestEvidenceError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl std::error::Error for MergeRequestEvidenceError {}
impl MergeRequest {
pub fn effective_review(&self, subject: &str) -> Option<&ReviewEvent> {
self.thread.iter().rev().find_map(|e|match e{MergeRequestThreadEvent::Review(v)if v.subject_ref==subject&&!self.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(r)if r.review_event_id==v.event_id))=>Some(v),_=>None})
}
/// Returns the recorded merged result, without resolving selectors or mutating state.
pub fn merged_result(&self) -> Result<&MergeEvent, MergeRequestEvidenceError> {
if self.state != MergeRequestState::Merged {
return Err(MergeRequestEvidenceError::NotMerged);
}
self.thread
.iter()
.rev()
.find_map(|event| match event {
MergeRequestThreadEvent::Merge(event) => Some(event),
_ => None,
})
.ok_or(MergeRequestEvidenceError::MergeResultMissing)
}
/// Validates the specific approval recorded by the merged result. A later
/// requirement review does not replace this integration evidence.
pub fn integration_approval(&self) -> Result<&ReviewEvent, MergeRequestEvidenceError> {
let merge = self.merged_result()?;
let review = self
.thread
.iter()
.find_map(|event| match event {
MergeRequestThreadEvent::Review(review)
if review.event_id == merge.approval_event_id =>
{
Some(review)
}
_ => None,
})
.ok_or(MergeRequestEvidenceError::ApprovalMissing)?;
if merge.approved_source_ref.trim().is_empty()
|| review.subject_ref != merge.approved_source_ref
{
return Err(MergeRequestEvidenceError::ApprovalSourceMismatch);
}
if review.sequence >= merge.sequence {
return Err(MergeRequestEvidenceError::ApprovalTimelineMismatch);
}
self.approval_evidence(review)?;
Ok(review)
}
fn approval_evidence(&self, review: &ReviewEvent) -> Result<(), MergeRequestEvidenceError> {
if self.thread.iter().any(|event| {
matches!(event, MergeRequestThreadEvent::ReviewRevoked(revoked)
if revoked.review_event_id == review.event_id)
}) {
return Err(MergeRequestEvidenceError::ApprovalRevoked);
}
if review.decision != ReviewDecision::Approve {
return Err(MergeRequestEvidenceError::ApprovalNotApproved);
}
let requested = self
.thread
.iter()
.find_map(|event| match event {
MergeRequestThreadEvent::ReviewRequested(requested)
if requested.event_id == review.request_event_id =>
{
Some(requested)
}
_ => None,
})
.ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?;
if requested.subject_ref != review.subject_ref
|| requested.ticket_item_revision != review.ticket_item_revision
|| requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects
|| requested.reviewer != review.reviewer
|| requested.sequence >= review.sequence
{
return Err(MergeRequestEvidenceError::ReviewRequestMismatch);
}
Ok(())
}
}
/// Finds an effective approval of the authoritative current Ticket revision and
/// exact linked source snapshot. Callers supply all linked requests and resolved
/// source refs (recorded merged refs for completion); this function does not read
/// authority, resolve selectors, require merged state, or mutate lifecycle state.
/// An explicit event id validates that approval only; `None` discovers any valid
/// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected.
pub fn requirement_approval<'a>(
requests: &'a [MergeRequest],
item_revision: &str,
subjects: &[MergeRequestReviewSubject],
event_id: Option<&str>,
) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> {
if requests.len() != subjects.len()
|| !same_source_snapshot(subjects, subjects)
|| requests.iter().any(|request| {
requests
.iter()
.filter(|other| other.merge_request_id == request.merge_request_id)
.count()
!= 1
|| !subjects
.iter()
.any(|subject| subject.merge_request_id == request.merge_request_id)
})
{
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
}
for request in requests {
for event in request.thread.iter().rev() {
let MergeRequestThreadEvent::Review(review) = event else {
continue;
};
if event_id.is_some_and(|id| id != review.event_id) {
continue;
}
let evidence = (|| {
request.approval_evidence(review)?;
if !request
.effective_review(&review.subject_ref)
.is_some_and(|effective| effective.event_id == review.event_id)
{
return Err(MergeRequestEvidenceError::ApprovalNotEffective);
}
if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() {
return Err(MergeRequestEvidenceError::ItemRevisionMismatch);
}
if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) {
return Err(MergeRequestEvidenceError::SourceSnapshotMismatch);
}
if !subjects.iter().any(|subject| {
subject.merge_request_id == request.merge_request_id
&& subject.subject_ref == review.subject_ref
}) {
return Err(MergeRequestEvidenceError::ApprovalSourceMismatch);
}
Ok(review)
})();
if evidence.is_ok() || event_id.is_some() {
return evidence;
}
}
}
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
}
fn same_source_snapshot(
left: &[MergeRequestReviewSubject],
right: &[MergeRequestReviewSubject],
) -> bool {
!left.is_empty()
&& left.len() == right.len()
&& left.iter().all(|subject| {
!subject.merge_request_id.trim().is_empty()
&& !subject.subject_ref.trim().is_empty()
&& left
.iter()
.filter(|other| other.merge_request_id == subject.merge_request_id)
.count()
== 1
&& right.iter().filter(|other| *other == subject).count() == 1
})
}
#[derive(Clone, Debug, Default)]
@@ -364,16 +598,8 @@ pub struct CompleteMergeRequest {
pub auth: MergeRequestAuth,
pub now: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct CompleteTicket {
pub ticket_id: String,
pub operation_id: String,
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
pub auth: MergeRequestAuth,
pub now: DateTime<Utc>,
}
/// Historical MR-owned Ticket completion payload, retained for reading existing events.
/// Canonical Ticket completion is owned by the Ticket domain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketCompletionEvent {
pub operation_id: String,
@@ -979,7 +1205,7 @@ impl MergeRequestStore {
}
let assignment_is_current: bool = transaction.query_row(
"SELECT EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
)",
params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id],
@@ -1098,227 +1324,6 @@ impl MergeRequestStore {
Ok(event)
}
pub fn complete_ticket(
&self,
mut input: CompleteTicket,
) -> Result<TicketCompletionEvent, MergeRequestError> {
nonempty("operation_id", &input.operation_id)?;
nonempty("item_revision", &input.item_revision)?;
nonempty(
"requirement_approval_event_id",
&input.requirement_approval_event_id,
)?;
if input.merge_request_ids.is_empty() {
return Err(MergeRequestError::NotReady(
"Ticket completion requires at least one linked Merge Request result".into(),
));
}
input.merge_request_ids.sort();
if input
.merge_request_ids
.windows(2)
.any(|ids| ids[0] == ids[1])
{
return Err(MergeRequestError::Validation(
"merge_request_ids must not contain duplicates".into(),
));
}
let mut connection = self.lock()?;
let transaction = connection.transaction()?;
let existing: Option<String> = transaction
.query_row(
"SELECT payload.value
FROM typed_ticket_event_attributes operation
JOIN typed_ticket_event_attributes payload
ON payload.workspace_id=operation.workspace_id
AND payload.ticket_id=operation.ticket_id
AND payload.event_index=operation.event_index
AND payload.key='ticket_completion'
WHERE operation.workspace_id=?1 AND operation.ticket_id=?2
AND operation.key='operation_id' AND operation.value=?3",
params![input.auth.workspace_id, input.ticket_id, input.operation_id],
|row| row.get(0),
)
.optional()?;
if let Some(payload) = existing {
let event: TicketCompletionEvent = json(&payload)?;
if event.item_revision == input.item_revision
&& event.merge_request_ids == input.merge_request_ids
&& event.requirement_approval_event_id == input.requirement_approval_event_id
&& event.completed_by == input.auth.actor()
{
return Ok(event);
}
return Err(MergeRequestError::Conflict(
"Ticket completion operation fingerprint mismatch".into(),
));
}
let current_revision =
current_ticket_revision(&transaction, &input.auth.workspace_id, &input.ticket_id)?
.ok_or(MergeRequestError::NotFound)?;
if current_revision != input.item_revision {
return Err(MergeRequestError::Conflict(format!(
"Ticket item revision changed from `{}` to `{current_revision}`; repeat requirement review before completion",
input.item_revision
)));
}
let state: String = transaction.query_row(
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![input.auth.workspace_id, input.ticket_id],
|row| row.get(0),
)?;
if state != "inprogress" {
return Err(MergeRequestError::Conflict(
"Ticket must be inprogress".into(),
));
}
let assignment_is_current: bool = transaction.query_row(
"SELECT EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
)",
params![
input.auth.workspace_id,
input.ticket_id,
input.auth.assignment_id
],
|row| row.get(0),
)?;
if !assignment_is_current {
return Err(MergeRequestError::Unauthorized(
"Ticket completion assignment changed before commit".into(),
));
}
let actual_ids =
linked_merge_request_ids(&transaction, &input.auth.workspace_id, &input.ticket_id)?;
if actual_ids != input.merge_request_ids {
return Err(MergeRequestError::Conflict(
"linked Merge Request set changed; refresh Ticket completion evidence".into(),
));
}
let mut requests = Vec::with_capacity(actual_ids.len());
let mut merged_subjects = Vec::with_capacity(actual_ids.len());
for merge_request_id in &actual_ids {
let request = load_mr(&transaction, &input.auth.workspace_id, merge_request_id)?
.ok_or(MergeRequestError::NotFound)?;
if request.state != MergeRequestState::Merged {
return Err(MergeRequestError::NotReady(format!(
"Merge Request `{merge_request_id}` has no merged result"
)));
}
let merge = request
.thread
.iter()
.rev()
.find_map(|event| match event {
MergeRequestThreadEvent::Merge(event) => Some(event),
_ => None,
})
.ok_or_else(|| {
MergeRequestError::Corrupt(format!(
"merged Merge Request `{merge_request_id}` has no MergeResult"
))
})?;
let review = request
.thread
.iter()
.find_map(|event| match event {
MergeRequestThreadEvent::Review(review)
if review.event_id == merge.approval_event_id
&& review.subject_ref == merge.approved_source_ref =>
{
Some(review)
}
_ => None,
})
.filter(|review| {
!request.thread.iter().any(|event| {
matches!(
event,
MergeRequestThreadEvent::ReviewRevoked(revoked)
if revoked.review_event_id == review.event_id
)
})
})
.ok_or_else(|| {
MergeRequestError::NotReady(format!(
"Merge Request `{merge_request_id}` integration approval is missing or revoked"
))
})?;
if review.decision != ReviewDecision::Approve {
return Err(MergeRequestError::NotReady(format!(
"Merge Request `{merge_request_id}` lacks an approved integration result"
)));
}
merged_subjects.push(MergeRequestReviewSubject {
merge_request_id: merge_request_id.clone(),
subject_ref: merge.approved_source_ref.clone(),
});
requests.push(request);
}
let has_requirement_attestation = requests.iter().any(|request| {
request.thread.iter().rev().any(|event| {
let MergeRequestThreadEvent::Review(review) = event else {
return false;
};
review.event_id == input.requirement_approval_event_id
&& review.decision == ReviewDecision::Approve
&& review.ticket_item_revision == input.item_revision
&& review.ticket_merge_request_subjects == merged_subjects
&& request
.effective_review(&review.subject_ref)
.is_some_and(|effective| effective.event_id == review.event_id)
})
});
if !has_requirement_attestation {
return Err(MergeRequestError::NotReady(
"no effective Reviewer approval attests the current Ticket revision and exact merged source set"
.into(),
));
}
let updated = transaction.execute(
"UPDATE typed_tickets
SET workflow_state='done',workflow_state_explicit=1,updated_at=?3
WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'",
params![
input.auth.workspace_id,
input.ticket_id,
input.now.to_rfc3339()
],
)?;
if updated != 1 {
return Err(MergeRequestError::Conflict(
"Ticket state changed during completion".into(),
));
}
let released = transaction.execute(
"DELETE FROM ticket_current_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
params![
input.auth.workspace_id,
input.ticket_id,
input.auth.assignment_id
],
)?;
if released != 1 {
return Err(MergeRequestError::Unauthorized(
"Ticket completion assignment changed while releasing it".into(),
));
}
let event = TicketCompletionEvent {
operation_id: input.operation_id,
ticket_id: input.ticket_id.clone(),
item_revision: input.item_revision,
merge_request_ids: input.merge_request_ids,
requirement_approval_event_id: input.requirement_approval_event_id,
completed_by: input.auth.actor(),
created_at: input.now,
};
ticket_completion_event(&transaction, &input.auth.workspace_id, &event)?;
transaction.commit()?;
Ok(event)
}
pub fn repair_selector_from(
&self,
i: RepairSelectorFrom,
@@ -1764,62 +1769,6 @@ fn ticket_event(
Ok(())
}
fn ticket_completion_event(
transaction: &Transaction<'_>,
workspace_id: &str,
event: &TicketCompletionEvent,
) -> Result<(), MergeRequestError> {
let event_index: i64 = transaction.query_row(
"SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, event.ticket_id],
|row| row.get(0),
)?;
transaction.execute(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)
VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Ticket requirements completed',?6)",
params![
workspace_id,
event.ticket_id,
event_index,
format!(
"worker:{}:{}",
event.completed_by.runtime_id, event.completed_by.worker_id
),
event.created_at.to_rfc3339(),
format!(
"Current Ticket revision `{}` was approved by Review `{}` and completed with Merge Requests: {}.",
event.item_revision,
event.requirement_approval_event_id,
event.merge_request_ids.join(", ")
)
],
)?;
for (key, value) in [
("operation_id", event.operation_id.clone()),
("item_revision", event.item_revision.clone()),
(
"requirement_approval_event_id",
event.requirement_approval_event_id.clone(),
),
(
"merge_request_ids",
serde_json::to_string(&event.merge_request_ids)
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
),
(
"ticket_completion",
serde_json::to_string(event)
.map_err(|error| MergeRequestError::Operation(error.to_string()))?,
),
] {
transaction.execute(
"INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)",
params![workspace_id, event.ticket_id, event_index, key, value],
)?;
}
Ok(())
}
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? {
SchemaState::Fresh => fresh(c),
+459
View File
@@ -0,0 +1,459 @@
use chrono::{TimeZone, Utc};
use merge_request::*;
fn actor() -> WorkerIdentity {
WorkerIdentity {
runtime_id: "runtime".into(),
worker_id: "reviewer".into(),
}
}
fn subjects() -> Vec<MergeRequestReviewSubject> {
vec![
MergeRequestReviewSubject {
merge_request_id: "MR-1".into(),
subject_ref: "source-one".into(),
},
MergeRequestReviewSubject {
merge_request_id: "MR-2".into(),
subject_ref: "source-two".into(),
},
]
}
fn add_review(
request: &mut MergeRequest,
event_id: &str,
revision: &str,
snapshot: &[MergeRequestReviewSubject],
decision: ReviewDecision,
) {
let subject = snapshot
.iter()
.find(|subject| subject.merge_request_id == request.merge_request_id)
.unwrap();
let sequence = request.thread.len() as u64 + 1;
let requested = ReviewRequestedEvent {
event_id: format!("request-{event_id}"),
sequence,
subject_ref: subject.subject_ref.clone(),
ticket_item_revision: revision.into(),
ticket_merge_request_subjects: snapshot.to_vec(),
requested_by: actor(),
reviewer: actor(),
created_at: request.created_at,
};
let review = ReviewEvent {
event_id: event_id.into(),
sequence: sequence + 1,
request_event_id: requested.event_id.clone(),
subject_ref: requested.subject_ref.clone(),
ticket_item_revision: revision.into(),
ticket_merge_request_subjects: snapshot.to_vec(),
decision,
body: String::new(),
findings: vec![],
reviewer: actor(),
created_at: request.created_at,
};
request
.thread
.push(MergeRequestThreadEvent::ReviewRequested(requested));
request.thread.push(MergeRequestThreadEvent::Review(review));
}
fn merged_requests() -> Vec<MergeRequest> {
let now = Utc.with_ymd_and_hms(2026, 10, 8, 12, 0, 0).unwrap();
let snapshot = subjects();
snapshot
.iter()
.map(|subject| {
let mut request = MergeRequest {
workspace_id: "W".into(),
merge_request_id: subject.merge_request_id.clone(),
repository_id: subject.merge_request_id.clone(),
state: MergeRequestState::Merged,
selector_from: Some("work/source".into()),
selector_to: "main".into(),
ticket_ids: vec!["T".into()],
created_at: now,
updated_at: now,
thread: vec![],
};
let approval_id = format!("integration-{}", subject.merge_request_id);
add_review(
&mut request,
&approval_id,
"revision-one",
&snapshot,
ReviewDecision::Approve,
);
request
.thread
.push(MergeRequestThreadEvent::Merge(MergeEvent {
event_id: format!("merge-{}", subject.merge_request_id),
sequence: 3,
operation_id: format!("operation-{}", subject.merge_request_id),
approval_event_id: approval_id,
approved_source_ref: subject.subject_ref.clone(),
target_ref_before: "target-before".into(),
target_ref_after: "target-after".into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
merged_by: actor(),
created_at: now,
}));
request
})
.collect()
}
#[test]
fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() {
let mut requests = merged_requests();
let snapshot = subjects();
assert_eq!(
requirement_approval(
&requests,
"revision-two",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
);
add_review(
&mut requests[0],
"postmerge",
"revision-two",
&snapshot,
ReviewDecision::Approve,
);
assert_eq!(
requests[0].integration_approval().unwrap().event_id,
"integration-MR-1"
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None)
.unwrap()
.event_id,
"postmerge"
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge"))
.unwrap()
.event_id,
"postmerge"
);
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&snapshot,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ApprovalNotEffective)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
}
#[test]
fn later_request_changes_prevents_older_requirement_approval_from_being_effective() {
let mut requests = merged_requests();
let snapshot = subjects();
add_review(
&mut requests[0],
"approved",
"revision-two",
&snapshot,
ReviewDecision::Approve,
);
add_review(
&mut requests[0],
"changes",
"revision-two",
&snapshot,
ReviewDecision::RequestChanges,
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("approved")),
Err(MergeRequestEvidenceError::ApprovalNotEffective)
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, Some("changes")),
Err(MergeRequestEvidenceError::ApprovalNotApproved)
);
assert!(requests[0].integration_approval().is_ok());
}
#[test]
fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() {
let requests = merged_requests();
let original = subjects();
assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok());
assert_eq!(
requirement_approval(
&requests,
"revision-two",
&original,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
);
let mut changed = original.clone();
changed[1].subject_ref = "changed-source-two".into();
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&changed,
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
let mut reversed = original.clone();
reversed.reverse();
assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok());
// Even after the caller refreshes the linked request collection, an old
// attestation cannot authorize completion against a smaller linked set.
assert_eq!(
requirement_approval(
&requests[..1],
"revision-one",
&original[..1],
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
assert_eq!(
requirement_approval(&requests, "revision-one", &original[..1], None),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&[original[0].clone(), original[0].clone()],
None
),
Err(MergeRequestEvidenceError::SourceSnapshotMismatch)
);
}
#[test]
fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() {
let mut requests = merged_requests();
let mut snapshot = subjects();
snapshot.pop();
requests.pop();
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None),
Err(MergeRequestEvidenceError::RequirementApprovalMissing)
);
add_review(
&mut requests[0],
"refreshed-set",
"revision-two",
&snapshot,
ReviewDecision::Approve,
);
assert_eq!(
requirement_approval(&requests, "revision-two", &snapshot, None)
.unwrap()
.event_id,
"refreshed-set"
);
assert_eq!(
requests[0].integration_approval().unwrap().event_id,
"integration-MR-1"
);
}
#[test]
fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request() {
let original = subjects();
for (case, expected) in [
("missing", MergeRequestEvidenceError::ReviewRequestMissing),
("revision", MergeRequestEvidenceError::ReviewRequestMismatch),
("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch),
("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch),
("source", MergeRequestEvidenceError::ApprovalSourceMismatch),
] {
let mut requests = merged_requests();
for event in &mut requests[0].thread {
match event {
MergeRequestThreadEvent::Review(review) if case == "missing" => {
review.request_event_id = "absent-request".into();
}
MergeRequestThreadEvent::ReviewRequested(requested) => match case {
"revision" => requested.ticket_item_revision = "other-revision".into(),
"snapshot" => {
requested.ticket_merge_request_subjects.pop();
}
"reviewer" => requested.reviewer.worker_id = "other-reviewer".into(),
"source" => requested.subject_ref = "unbound-source".into(),
_ => {}
},
MergeRequestThreadEvent::Review(review) if case == "source" => {
review.subject_ref = "unbound-source".into();
}
_ => {}
}
}
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&original,
Some("integration-MR-1")
),
Err(expected),
"{case}"
);
}
}
#[test]
fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr() {
let mut requests = merged_requests();
let created_at = requests[0].created_at;
requests[0]
.thread
.push(MergeRequestThreadEvent::ReviewRevoked(ReviewRevokedEvent {
event_id: "revocation".into(),
sequence: 4,
review_event_id: "integration-MR-1".into(),
subject_ref: "source-one".into(),
reason: String::new(),
revoked_by: actor(),
created_at,
}));
assert_eq!(
requirement_approval(
&requests,
"revision-one",
&subjects(),
Some("integration-MR-1")
),
Err(MergeRequestEvidenceError::ApprovalRevoked)
);
assert_eq!(
requirement_approval(&requests, "revision-one", &subjects(), None)
.unwrap()
.event_id,
"integration-MR-2"
);
}
#[test]
fn integration_approval_must_strictly_predate_the_merge_sequence() {
for approval_sequence in [2, 3, 4] {
let mut requests = merged_requests();
for event in &mut requests[0].thread {
if let MergeRequestThreadEvent::Review(review) = event {
review.sequence = approval_sequence;
}
}
let result = requests[0].integration_approval();
if approval_sequence < 3 {
assert!(result.is_ok());
} else {
assert_eq!(
result,
Err(MergeRequestEvidenceError::ApprovalTimelineMismatch),
"approval sequence {approval_sequence}"
);
}
}
}
#[test]
fn blank_saved_source_is_not_valid_integration_evidence_even_when_the_review_matches() {
for source in ["", " \t\n"] {
let mut requests = merged_requests();
for event in &mut requests[0].thread {
match event {
MergeRequestThreadEvent::ReviewRequested(requested) => {
requested.subject_ref = source.into();
}
MergeRequestThreadEvent::Review(review) => {
review.subject_ref = source.into();
}
MergeRequestThreadEvent::Merge(merge) => {
merge.approved_source_ref = source.into();
}
_ => {}
}
}
assert_eq!(
requests[0].integration_approval(),
Err(MergeRequestEvidenceError::ApprovalSourceMismatch)
);
}
}
#[test]
fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages() {
for (error, code) in [
(MergeRequestEvidenceError::NotMerged, "not_merged"),
(
MergeRequestEvidenceError::MergeResultMissing,
"merge_result_missing",
),
(
MergeRequestEvidenceError::ApprovalMissing,
"approval_missing",
),
(
MergeRequestEvidenceError::ApprovalRevoked,
"approval_revoked",
),
(
MergeRequestEvidenceError::ApprovalNotApproved,
"approval_not_approved",
),
(
MergeRequestEvidenceError::ApprovalSourceMismatch,
"approval_source_mismatch",
),
(
MergeRequestEvidenceError::ApprovalTimelineMismatch,
"approval_timeline_mismatch",
),
(
MergeRequestEvidenceError::ReviewRequestMissing,
"review_request_missing",
),
(
MergeRequestEvidenceError::ReviewRequestMismatch,
"review_request_mismatch",
),
(
MergeRequestEvidenceError::ApprovalNotEffective,
"approval_not_effective",
),
(
MergeRequestEvidenceError::ItemRevisionMismatch,
"item_revision_mismatch",
),
(
MergeRequestEvidenceError::SourceSnapshotMismatch,
"source_snapshot_mismatch",
),
(
MergeRequestEvidenceError::RequirementApprovalMissing,
"requirement_approval_missing",
),
] {
assert_eq!(error.code(), code);
assert_ne!(error.code(), error.as_str());
}
}
+275 -134
View File
@@ -39,7 +39,7 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("db");
let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(),
@@ -166,7 +166,7 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects()
}
#[test]
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_merge() {
let (d, s) = fixture();
open(&s);
let review = approve(&s, "opaque-source-ref", "token");
@@ -226,43 +226,14 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
})
.unwrap();
assert_eq!(replayed, merged);
let completed = s
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-op".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into()],
requirement_approval_event_id: merged.approval_event_id.clone(),
auth: auth(),
now: at(7),
})
.unwrap();
assert_eq!(completed.merge_request_ids, vec!["MR"]);
let connection = Connection::open(d.path().join("db")).unwrap();
let (state, assignment_exists): (String, bool) = connection
.query_row(
"SELECT workflow_state, EXISTS(
SELECT 1 FROM ticket_current_worker_assignments
WHERE workspace_id='W' AND ticket_id='T'
) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(state, "done");
assert!(!assignment_exists);
let replayed_completion = s
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-op".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into()],
requirement_approval_event_id: merged.approval_event_id.clone(),
auth: auth(),
now: at(8),
})
.unwrap();
assert_eq!(replayed_completion, completed);
let (state, active): (String, bool) = connection.query_row(
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).unwrap();
assert_eq!(state, "inprogress");
assert!(active);
let json = serde_json::to_string(&mr).unwrap();
for banned in [
"revision_id",
@@ -864,12 +835,12 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
let (dir, store) = fixture();
open(&store);
let integration_approval = approve(&store, "subject", "token-integration");
store
let merge = store
.complete(CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "merge".into(),
approval_event_id: integration_approval.event_id,
approval_event_id: integration_approval.event_id.clone(),
current_subject_ref: "subject".into(),
target_ref_before: "target-before".into(),
target_ref_after: "target-after".into(),
@@ -894,6 +865,26 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
.unwrap();
drop(connection);
let snapshot = vec![MergeRequestReviewSubject {
merge_request_id: "MR".into(),
subject_ref: merge.approved_source_ref.clone(),
}];
let before_refresh = store.list_for_ticket("W", "T").unwrap();
assert_eq!(
requirement_approval(
&before_refresh,
"T:1",
&snapshot,
Some(&integration_approval.event_id),
),
Err(MergeRequestEvidenceError::ItemRevisionMismatch)
);
assert_eq!(before_refresh[0].merged_result().unwrap(), &merge);
assert_eq!(
before_refresh[0].integration_approval().unwrap(),
&integration_approval
);
store
.register_reviewer_child_session(RegisterReviewerChildSession {
workspace_id: "W".into(),
@@ -920,7 +911,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
now: at(7),
})
.unwrap();
let requirement_approval = store
let requirement_review = store
.submit_review(SubmitMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
@@ -932,17 +923,33 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
now: at(8),
})
.unwrap();
store
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete-post-rescope".into(),
item_revision: "T:1".into(),
merge_request_ids: vec!["MR".into()],
requirement_approval_event_id: requirement_approval.event_id,
auth: auth(),
now: at(9),
})
.unwrap();
let refreshed = store.list_for_ticket("W", "T").unwrap();
assert_eq!(
requirement_approval(
&refreshed,
"T:1",
&snapshot,
Some(&requirement_review.event_id),
)
.unwrap(),
&requirement_review
);
assert_eq!(
requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(),
&requirement_review
);
assert_eq!(refreshed[0].merged_result().unwrap(), &merge);
assert_eq!(
refreshed[0].integration_approval().unwrap(),
&integration_approval
);
let connection = Connection::open(dir.path().join("db")).unwrap();
let merge_payload: String = connection.query_row(
"SELECT payload_json FROM merge_request_thread_events WHERE workspace_id='W' AND merge_request_id='MR' AND kind='merge'",
[],
|row| row.get(0),
).unwrap();
assert_eq!(merge_payload, serde_json::to_string(&merge).unwrap());
}
#[test]
@@ -978,19 +985,19 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
}
#[test]
fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() {
fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
let (dir, store) = fixture();
open_for(&store, "MR", "R");
open_for(&store, "MR-2", "R2");
let first = approve_for(&store, "MR", "R", "subject-one", "token-one");
let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two");
store
let first_merge = store
.complete(CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "merge-one".into(),
approval_event_id: first.event_id,
approval_event_id: first.event_id.clone(),
current_subject_ref: "subject-one".into(),
target_ref_before: "target-one-before".into(),
target_ref_after: "target-one-after".into(),
@@ -1000,30 +1007,33 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
now: at(5),
})
.unwrap();
let connection = Connection::open(dir.path().join("db")).unwrap();
let (state, assigned): (String, bool) = connection
.query_row(
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(state, "inprogress");
assert!(assigned);
assert!(matches!(
store.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into(), "MR-2".into()],
requirement_approval_event_id: second.event_id.clone(),
auth: auth(),
now: at(6),
}),
Err(MergeRequestError::NotReady(_))
));
let partial = store.get_by_id("W", "MR-2").unwrap();
assert_eq!(
partial.integration_approval(),
Err(MergeRequestEvidenceError::NotMerged)
);
store
let connection = Connection::open(dir.path().join("db")).unwrap();
let assert_ticket_unchanged = || {
let (state, assigned, active, state_changes): (String, bool, bool, i64) = connection
.query_row(
"SELECT workflow_state,
EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T'),
EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T'),
(SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='W' AND ticket_id='T' AND kind='state_changed')
FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(state, "inprogress");
assert!(assigned);
assert!(active);
assert_eq!(state_changes, 0);
};
assert_ticket_unchanged();
let second_merge = store
.complete(CompleteMergeRequest {
merge_request_id: "MR-2".into(),
ticket_id: "T".into(),
@@ -1038,63 +1048,194 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
now: at(7),
})
.unwrap();
assert!(matches!(
store.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete".into(),
item_revision: "stale".into(),
merge_request_ids: vec!["MR".into(), "MR-2".into()],
requirement_approval_event_id: second.event_id.clone(),
auth: auth(),
now: at(8),
}),
Err(MergeRequestError::Conflict(_))
));
assert!(matches!(
store.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete-missing-attestation".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into(), "MR-2".into()],
requirement_approval_event_id: "missing-review".into(),
auth: auth(),
now: at(8),
}),
Err(MergeRequestError::NotReady(_))
));
assert!(matches!(
store.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into()],
requirement_approval_event_id: second.event_id.clone(),
auth: auth(),
now: at(8),
}),
Err(MergeRequestError::Conflict(_))
));
let completed = store
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR-2".into(), "MR".into()],
requirement_approval_event_id: second.event_id.clone(),
auth: auth(),
now: at(9),
assert_ticket_unchanged();
let requests = store.list_for_ticket("W", "T").unwrap();
let snapshot = requests
.iter()
.map(|request| MergeRequestReviewSubject {
merge_request_id: request.merge_request_id.clone(),
subject_ref: request.merged_result().unwrap().approved_source_ref.clone(),
})
.collect::<Vec<_>>();
assert_eq!(
requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(),
&second
);
let first_request = requests
.iter()
.find(|request| request.merge_request_id == "MR")
.unwrap();
let replay = store
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
operation_id: "ticket-complete".into(),
item_revision: "t".into(),
merge_request_ids: vec!["MR".into(), "MR-2".into()],
requirement_approval_event_id: second.event_id.clone(),
auth: auth(),
now: at(10),
})
let second_request = requests
.iter()
.find(|request| request.merge_request_id == "MR-2")
.unwrap();
assert_eq!(replay, completed);
assert_eq!(first_request.merged_result().unwrap(), &first_merge);
assert_eq!(second_request.merged_result().unwrap(), &second_merge);
assert_eq!(first_request.integration_approval().unwrap(), &first);
assert_eq!(second_request.integration_approval().unwrap(), &second);
}
#[test]
fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migration() {
let (dir, _store) = fixture();
let connection = Connection::open(dir.path().join("db")).unwrap();
let payload = r#"{
"operation_id": "historical-operation",
"ticket_id": "T",
"item_revision": "historical-revision",
"merge_request_ids": ["MR-2", "MR"],
"requirement_approval_event_id": "historical-review",
"completed_by": {"runtime_id": "runtime", "worker_id": "coder"},
"created_at": "2026-07-26T12:00:07Z"
}"#;
connection.execute_batch(
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');"
).unwrap();
connection
.execute(
"INSERT INTO typed_ticket_event_attributes VALUES('W','T',0,'ticket_completion',?1)",
[payload],
)
.unwrap();
migrate(&connection).unwrap();
let (saved_payload, kind, from_state, to_state, heading, body, state): (String, String, String, String, String, String, String) = connection.query_row(
"SELECT attribute.value,event.kind,event.from_state,event.to_state,event.heading,event.body,ticket.workflow_state
FROM typed_ticket_event_attributes attribute
JOIN typed_ticket_events event USING(workspace_id,ticket_id,event_index)
JOIN typed_tickets ticket USING(workspace_id,ticket_id)
WHERE attribute.workspace_id='W' AND attribute.ticket_id='T' AND attribute.key='ticket_completion'",
[],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?)),
).unwrap();
assert_eq!(saved_payload, payload);
assert_eq!(kind, "state_changed");
assert_eq!(from_state, "inprogress");
assert_eq!(to_state, "done");
assert_eq!(heading, "Ticket requirements completed");
assert_eq!(body, "historical body");
assert_eq!(state, "done");
let historical: TicketCompletionEvent = serde_json::from_str(&saved_payload).unwrap();
assert_eq!(
serde_json::to_value(&historical).unwrap(),
serde_json::from_str::<serde_json::Value>(payload).unwrap()
);
}
#[test]
fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_approval() {
for (case, expected) in [
(
"merge-missing",
MergeRequestEvidenceError::MergeResultMissing,
),
(
"approval-missing",
MergeRequestEvidenceError::ApprovalMissing,
),
("revoked", MergeRequestEvidenceError::ApprovalRevoked),
(
"source-mismatch",
MergeRequestEvidenceError::ApprovalSourceMismatch,
),
(
"not-approved",
MergeRequestEvidenceError::ApprovalNotApproved,
),
(
"request-missing",
MergeRequestEvidenceError::ReviewRequestMissing,
),
(
"request-mismatch",
MergeRequestEvidenceError::ReviewRequestMismatch,
),
(
"approval-after-merge",
MergeRequestEvidenceError::ApprovalTimelineMismatch,
),
] {
let (dir, store) = fixture();
open(&store);
let review = approve(&store, "merged-source", "integration-token");
let merge = store
.complete(CompleteMergeRequest {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
operation_id: "merge".into(),
approval_event_id: review.event_id.clone(),
current_subject_ref: "merged-source".into(),
target_ref_before: "before".into(),
target_ref_after: "after".into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
})
.unwrap();
let persisted = store.get_by_id("W", "MR").unwrap();
assert_eq!(persisted.merged_result().unwrap(), &merge);
assert_eq!(persisted.integration_approval().unwrap(), &review);
let connection = Connection::open(dir.path().join("db")).unwrap();
match case {
"merge-missing" | "approval-missing" | "request-missing" => {
let kind = match case {
"merge-missing" => "merge",
"approval-missing" => "review",
_ => "review_requested",
};
connection.execute(
"DELETE FROM merge_request_thread_events WHERE workspace_id='W' AND merge_request_id='MR' AND kind=?1",
[kind],
).unwrap();
}
"revoked" => {
store
.revoke_review(RevokeMergeRequestReview {
merge_request_id: "MR".into(),
ticket_id: "T".into(),
review_event_id: review.event_id.clone(),
reason: "invalid integration evidence".into(),
auth: auth(),
now: at(6),
})
.unwrap();
}
"approval-after-merge" => {
let mut altered = review.clone();
altered.sequence = merge.sequence + 1;
connection.execute(
"UPDATE merge_request_thread_events SET payload_json=?1,sequence=?2 WHERE workspace_id='W' AND merge_request_id='MR' AND kind='review'",
rusqlite::params![serde_json::to_string(&altered).unwrap(), altered.sequence],
).unwrap();
}
_ => {
let mut altered = review.clone();
match case {
"source-mismatch" => altered.subject_ref = "different-source".into(),
"not-approved" => altered.decision = ReviewDecision::RequestChanges,
"request-mismatch" => {
altered.ticket_item_revision = "different-revision".into()
}
_ => unreachable!(),
}
connection.execute(
"UPDATE merge_request_thread_events SET payload_json=?1 WHERE workspace_id='W' AND merge_request_id='MR' AND kind='review'",
[serde_json::to_string(&altered).unwrap()],
).unwrap();
}
}
let reloaded = store.get_by_id("W", "MR").unwrap();
assert_eq!(reloaded.integration_approval(), Err(expected), "{case}");
let (state, assigned): (String, bool) = connection.query_row(
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).unwrap();
assert_eq!(state, "inprogress", "{case}");
assert!(assigned, "{case}");
}
}
+112 -43
View File
@@ -324,7 +324,6 @@ impl_openapi_schema!(
SubmitMergeRequestReviewRequest,
TextResponse,
TicketCloseRecordRequest,
TicketCompletionEvent,
TicketDependencyCheckResponse,
TicketDetail,
TicketDoctorResponse,
@@ -350,6 +349,7 @@ impl_openapi_schema!(
TicketRoleAssignmentsResponse,
TicketShowRequest,
TicketStateChangeRequest,
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
RuntimeManagementApiError,
@@ -2917,7 +2917,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketThreadEventRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_change_add(
&self,
#[extension] context: ServerRequestContext,
@@ -2933,7 +2933,7 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketIntakeSummaryRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_field_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2942,7 +2942,7 @@ pub trait ServerApi {
#[path] field: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_workflow_state_set(
&self,
#[extension] context: ServerRequestContext,
@@ -2950,6 +2950,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateUpdateRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_mark_ready_record(
&self,
@@ -3047,15 +3055,15 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: CompleteMergeRequestRequest,
) -> Result<MergeEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_complete(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: CompleteTicketRequest,
) -> Result<TicketCompletionEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close_record(
&self,
#[extension] context: ServerRequestContext,
@@ -3150,9 +3158,10 @@ pub trait ServerApi {
#[body] request: CancelTicketImplementationRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_transition(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserTransitionTicketStateRequest,
@@ -3161,6 +3170,7 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_ready(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketMarkReadyRequest,
@@ -3177,14 +3187,16 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_queue(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserQueueTicketRequest,
) -> Result<TicketQueueResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserCloseTicketRequest,
@@ -4239,22 +4251,27 @@ pub struct InitialRepositoryIntent {
pub default_ref: Option<String>,
}
/// Request for atomically creating a Workspace and its initial Repository.
/// Request for atomically creating a Workspace with an optional initial Repository.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceCreateRequest {
pub operation_key: String,
pub display_name: String,
pub repository: InitialRepositoryIntent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<InitialRepositoryIntent>,
}
/// Response returned after atomically creating a Workspace and its first Repository.
/// Response returned after atomically creating a Workspace.
///
/// On replay, Workspace/config and the Repository at the originally requested key are
/// current authority, not historical snapshots. Repository is null when creation omitted
/// it or that key has since been removed/renamed; replay never recreates an asset.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceCreateResponse {
pub workspace: WorkspaceSummary,
pub repository: WorkspaceRepositoryRecord,
pub repository: Option<WorkspaceRepositoryRecord>,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub config_revision: u64,
@@ -7404,6 +7421,15 @@ pub struct TicketActionEligibility {
pub blockers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum TicketSourceRefObservation {
Observed {},
Unavailable { code: String },
NotRequired {},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -7416,6 +7442,8 @@ pub struct TicketMergeRequestSummary {
pub selector_to: String,
pub updated_at: String,
pub current_subject_ref: Option<String>,
pub source_ref_observation: TicketSourceRefObservation,
pub integration_evidence_error: Option<String>,
pub review_subject_ref: Option<String>,
pub review_requested_at: Option<String>,
pub review_submitted_at: Option<String>,
@@ -7636,9 +7664,10 @@ transparent_ticket_dto!(
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateChange);
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
transparent_ticket_dto!(
CreateTicketOrchestrationPlanRequest,
@@ -7763,9 +7792,11 @@ pub struct BrowserEditTicketRequest {
#[serde(deny_unknown_fields)]
pub struct BrowserTransitionTicketStateRequest {
pub state: BrowserTicketWorkflowState,
pub reason: Option<String>,
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: BrowserTicketWorkflowState,
pub reason: String,
pub body: Option<String>,
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7796,6 +7827,9 @@ pub struct BrowserQueueTicketRequest {}
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserCloseTicketRequest {
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: BrowserTicketWorkflowState,
pub resolution: String,
}
@@ -8174,22 +8208,12 @@ pub struct CompleteMergeRequestRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompleteTicketRequest {
pub operation_id: String,
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketCompletionEvent {
pub operation_id: String,
pub ticket_id: String,
pub item_revision: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
pub completed_by: MergeRequestWorkerIdentity,
pub created_at: String,
pub operation_key: String,
pub expected_item_revision: String,
pub expected_state: ticket::TicketWorkflowState,
pub reason: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -11450,13 +11474,6 @@ mod tests {
#[test]
fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() {
let operations = ServerApiMetadata::OPERATIONS;
assert_eq!(
operations
.iter()
.filter(|operation| operation.operation_id.starts_with("ticket_"))
.count(),
38
);
assert_eq!(
operations
.iter()
@@ -11472,6 +11489,16 @@ mod tests {
11
);
for (operation_id, method, path) in [
(
"ticket_state_update",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/state-update",
),
(
"ticket_complete",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/complete",
),
(
"ticket_relation_query",
HttpMethod::Post,
@@ -12193,12 +12220,27 @@ mod tests {
let request = WorkspaceCreateRequest {
operation_key: "workspace-create-1".to_string(),
display_name: "Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: "/srv/repositories/main".to_string(),
default_ref: Some("develop".to_string()),
},
}),
};
for repository in [None, Some(serde_json::Value::Null)] {
let mut wire =
serde_json::json!({"operation_key": "empty", "display_name": "Workspace"});
if let Some(repository) = repository {
wire["repository"] = repository;
}
let decoded: WorkspaceCreateRequest = serde_json::from_value(wire).unwrap();
assert!(decoded.repository.is_none());
assert!(
serde_json::to_value(decoded)
.unwrap()
.get("repository")
.is_none()
);
}
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation_key"], "workspace-create-1");
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
@@ -12529,6 +12571,34 @@ mod tests {
assert!(serde_json::from_value::<RuntimeConnectionTestResponse>(unknown).is_err());
}
#[test]
fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() {
let request = serde_json::json!({
"operation_key": "investigation-done",
"expected_item_revision": "ticket:1",
"expected_state": "planning",
"reason": "Question answered in the thread"
});
let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap();
assert!(parsed.references.is_empty());
for field in [
"operation_key",
"expected_item_revision",
"expected_state",
"reason",
] {
let mut missing = request.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<CompleteTicketRequest>(missing).is_err(),
"{field}"
);
}
let mut old = request;
old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment");
assert!(serde_json::from_value::<CompleteTicketRequest>(old).is_err());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
@@ -13377,7 +13447,6 @@ mod openapi_artifact_tests {
"merge_request_review_revoke",
"merge_request_review_submit",
"merge_request_reviewer_child_register",
"ticket_complete",
"runtime_resource_fetch",
"worker_control_cancel",
"worker_control_input",
+1653 -819
View File
File diff suppressed because it is too large Load Diff
+46 -86
View File
@@ -134,18 +134,15 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body`
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
"Append a typed Ticket implementation_report event. `body` is Markdown.";
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
The backend atomically validates and normalizes every persisted repository target, requires at least one \
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
Targets are optional; provided targets are validated against the Workspace catalog without Git resolution. \
Records one typed state_changed event and transitions planning -> ready. `reason` is optional.";
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \
The backend applies the same optional catalog targets validation as TicketMarkReady and commits the summary, \
state_changed event, effective targets, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason.";
const QUEUE_DESCRIPTION: &str = "Queue only the explicitly requested Ticket for Orchestrator routing through the typed \
Ticket backend, regardless of state or dependencies. Dependency diagnostics are retained; no dependencies are auto-queued.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Change Ticket state with a bounded audited state_changed event. \
State and dependency metadata do not impose an implementation phase path. Reopening does not revive released work identities.";
const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
backend. The backend sets the Ticket state to closed and appends a close event.";
const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \
@@ -321,6 +318,18 @@ impl TicketBackend for TicketToolBackend {
self.backend.set_workflow_state(id, change)
}
fn update_state(
&self,
ticket: &str,
request: crate::TicketStateUpdate,
) -> TicketResult<Ticket> {
self.backend.update_state(ticket, request)
}
fn complete(&self, ticket: &str, request: crate::TicketCompletion) -> TicketResult<Ticket> {
self.backend.complete(ticket, request)
}
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
self.backend.mark_ready(id, request)
}
@@ -1953,7 +1962,7 @@ mod tests {
) -> crate::Result<crate::ResolvedTicketTarget> {
Ok(crate::ResolvedTicketTarget {
repository_key: repository_key.to_owned(),
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
ref_selector: Some(ref_selector.unwrap_or("develop").to_owned()),
access: crate::TicketTargetAccess::ReadOnly,
})
}
@@ -2061,15 +2070,21 @@ mod tests {
}
#[test]
fn state_tool_description_explains_queued_acceptance() {
fn state_tool_description_explains_phase_independence() {
let temp = TempDir::new().unwrap();
let definition = ticket_tools(backend(&temp))
.into_iter()
.find(|definition| definition().0.name == "TicketWorkflowState")
.expect("state tool exists");
let (meta, _) = definition();
assert!(meta.description.contains("queued -> inprogress"));
assert!(meta.description.contains("implementation side effects"));
assert!(
meta.description
.contains("do not impose an implementation phase path")
);
assert!(
meta.description
.contains("does not revive released work identities")
);
}
#[test]
@@ -2668,10 +2683,10 @@ mod tests {
)
.await
.unwrap();
assert!(queued.summary.contains("2 ticket(s)"));
assert!(queued.summary.contains("1 ticket(s)"));
let queued_content = queued.content.unwrap();
assert!(queued_content.contains(&target_key));
assert!(queued_content.contains(&dependency_key));
assert!(!queued_content.contains(&dependency_key));
assert!(!queued_content.contains(&target.id));
assert!(!queued_content.contains(&dependency.id));
@@ -2931,79 +2946,24 @@ mod tests {
}
#[tokio::test]
async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() {
async fn ticket_workflow_tool_allows_direct_state_changes_without_phase_path() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
let mut ready_input = NewTicket::new("Ready Bypass");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let ready_error = workflow
.execute(
&json!({
"ticket": ready.id,
"from": "ready",
"to": "inprogress",
"reason": "bypass_queue",
"body": "Should not bypass Queue.\n"
})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(
ready_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut done_input = NewTicket::new("Backward Bypass");
done_input.workflow_state = Some(TicketWorkflowState::Done);
let done = backend.create(done_input).unwrap();
let backward_error = workflow
.execute(
&json!({
"ticket": done.id,
"from": "done",
"to": "planning",
"reason": "backwards",
"body": "Should not move backwards.\n"
})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(
backward_error
.to_string()
.contains("invalid ticket workflow transition")
);
let mut queued_input = NewTicket::new("Skip Bypass");
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
let queued = backend.create(queued_input).unwrap();
let skip_error = workflow
.execute(
&json!({
"ticket": queued.id,
"from": "queued",
"to": "done",
"reason": "skip_inprogress",
"body": "Should not skip inprogress.\n"
})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(
skip_error
.to_string()
.contains("invalid ticket workflow transition")
);
for (from, to) in [
(TicketWorkflowState::Ready, TicketWorkflowState::InProgress),
(TicketWorkflowState::Done, TicketWorkflowState::Planning),
(TicketWorkflowState::Queued, TicketWorkflowState::Done),
] {
let mut input = NewTicket::new(format!("{from} to {to}"));
input.workflow_state = Some(from);
let ticket = backend.create(input).unwrap();
workflow.execute(&json!({"ticket": ticket.id, "from": from.as_str(), "to": to.as_str(), "reason": "explicit_update", "body": "State metadata updated."}).to_string(), Default::default()).await.unwrap();
assert_eq!(
backend.show(ticket.id.into()).unwrap().meta.workflow_state,
to
);
}
}
#[tokio::test]
+55 -32
View File
@@ -199,30 +199,40 @@ fn prompt_create_request_with_io(
if display_name.is_empty() {
return Ok(None);
}
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
return Ok(None);
};
let uri = uri.trim().to_string();
if uri.is_empty() {
writeln!(output, "Repository path/URI is required.")?;
return Ok(None);
}
let repository_key = loop {
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
else {
return Ok(None);
};
if repository_key.trim().is_empty() {
writeln!(output, "Repository key is required.")?;
continue;
}
break repository_key;
};
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
let Some(uri) = prompt_line(
input,
output,
"Initial repository absolute path/URI (optional, Enter to skip): ",
)?
else {
return Ok(None);
};
let default_ref = default_ref.trim().to_string();
let uri = uri.trim().to_string();
let repository = if uri.is_empty() {
None
} else {
let repository_key = loop {
let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")?
else {
return Ok(None);
};
if repository_key.trim().is_empty() {
writeln!(output, "Repository key is required.")?;
continue;
}
break repository_key;
};
let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")?
else {
return Ok(None);
};
let default_ref = default_ref.trim().to_string();
Some(CreateBackendWorkspaceRepository {
uri,
repository_key,
default_ref: (!default_ref.is_empty()).then_some(default_ref),
})
};
let operation_key = format!(
"tui-workspace-create-{}-{}",
std::process::id(),
@@ -234,11 +244,7 @@ fn prompt_create_request_with_io(
Ok(Some(CreateBackendWorkspaceRequest {
operation_key,
display_name,
repository: CreateBackendWorkspaceRepository {
uri,
repository_key,
default_ref: (!default_ref.is_empty()).then_some(default_ref),
},
repository,
}))
}
@@ -286,15 +292,29 @@ mod tests {
);
}
#[test]
fn workspace_creation_without_initial_repository_skips_repository_prompts() {
let (request, output) = prompt_create("Workspace\n\n");
assert!(request.unwrap().repository.is_none());
assert!(!output.contains("Repository key (required)"));
assert!(!output.contains("Default ref"));
}
#[test]
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
let request = request.expect("complete input should create a request");
assert_eq!(request.display_name, "Platform");
assert_eq!(request.repository.uri, "/srv/platform");
assert_eq!(request.repository.repository_key, "platform");
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
assert_eq!(request.repository.as_ref().unwrap().uri, "/srv/platform");
assert_eq!(
request.repository.as_ref().unwrap().repository_key,
"platform"
);
assert_eq!(
request.repository.as_ref().unwrap().default_ref.as_deref(),
Some("develop")
);
assert!(output.contains("Repository key (required): "));
assert!(!output.contains("Repository key [main]"));
}
@@ -304,7 +324,10 @@ mod tests {
let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
let request = request.expect("a later explicit key should create a request");
assert_eq!(request.repository.repository_key, "platform");
assert_eq!(
request.repository.as_ref().unwrap().repository_key,
"platform"
);
assert_eq!(output.matches("Repository key is required.").count(), 2);
assert_eq!(output.matches("Repository key (required): ").count(), 3);
}
@@ -314,7 +337,7 @@ mod tests {
let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n");
assert_eq!(
request.unwrap().repository.repository_key,
request.unwrap().repository.unwrap().repository_key,
" platform ",
"the Backend must validate the creator's exact Repository key"
);
@@ -325,7 +348,7 @@ mod tests {
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
assert_eq!(
request.unwrap().repository.repository_key,
request.unwrap().repository.unwrap().repository_key,
"main",
"main remains valid when the creator enters it explicitly"
);
+204 -4
View File
@@ -1303,10 +1303,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
});
});
let alerter_for_worker = alerter.clone();
worker.on_warning(move |message| {
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned());
});
wire_engine_warnings(worker, alerter);
// History-append broadcasts (previously `Event::SystemMessage`)
// have been removed: every persistent history item is now committed
@@ -1315,6 +1312,17 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
// per-item commit channel is wired at the top of this function.
}
/// Forward genuine Engine warnings to both live clients and the alert snapshot.
fn wire_engine_warnings<C: LlmClient, A: Send + Sync>(
engine: &mut agen::Engine<C, agen::state::Mutable, A>,
alerter: &Alerter,
) {
let alerter = alerter.clone();
engine.on_warning(move |message| {
alerter.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned());
});
}
fn validate_memory_lifecycle_targets(
memory: &manifest::MemoryFeatureProfileConfig,
subjektiv: &manifest::SubjektivFeatureProfileConfig,
@@ -3906,6 +3914,198 @@ mod tests {
use tempfile::TempDir;
use tokio::net::UnixListener;
mod engine_alerts {
use super::*;
use agen::llm_client::event::Event as LlmEvent;
use agen::llm_client::{ClientError, Item, Request, ToolCallCompletionSupport};
use agen::tool::{
Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolOutputLimits,
};
use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode};
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Clone)]
struct ScriptedClient {
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl ScriptedClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses.into())),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait::async_trait]
impl LlmClient for ScriptedClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
ToolCallCompletionSupport::ResponseComplete
}
async fn stream(
&self,
request: Request,
) -> Result<
Pin<Box<dyn futures::Stream<Item = Result<LlmEvent, ClientError>> + Send>>,
ClientError,
> {
self.requests.lock().unwrap().push(request);
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.expect("unexpected LLM request");
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
struct LargeOutputTool;
#[async_trait::async_trait]
impl Tool for LargeOutputTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
summary: "Fixed large output".into(),
content: Some("useful output line\n".repeat(512)),
attachments: Vec::new(),
})
}
}
fn finished_response() -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "Done"),
LlmEvent::text_block_stop(0, None),
]
}
#[tokio::test]
async fn truncated_tool_output_preserves_model_result_without_live_or_buffered_alerts() {
let client = ScriptedClient::new(vec![
vec![
LlmEvent::tool_use_start(0, "large-call", "large_output"),
LlmEvent::tool_input_delta(0, "{}"),
LlmEvent::tool_use_stop(0),
],
finished_response(),
]);
let requests = client.requests.clone();
let mut engine = Engine::new(client);
// Isolate output limiting from the independent early-dispatch warning.
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
engine.set_tool_output_limits(Some(ToolOutputLimits {
default_max_bytes: 256,
per_tool: Default::default(),
}));
engine.register_tool(Arc::new(|| {
(
ToolMeta::new("large_output")
.input_schema(serde_json::json!({"type": "object"})),
Arc::new(LargeOutputTool),
)
}));
let (tx, _) = broadcast::channel(8);
let alerter = Alerter::new(tx);
let (_, mut live) = alerter.subscribe_with_snapshot();
wire_engine_warnings(&mut engine, &alerter);
let output = engine.run(&mut History::new(), "Run the tool").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
let result = requests[1].items.iter().find(|item| {
matches!(item, Item::ToolResult { call_id, .. } if call_id == "large-call")
}).expect("the next model request must include the bounded tool result");
match result {
Item::ToolResult {
summary,
content: Some(content),
is_error,
disposition,
..
} => {
assert_eq!(summary, "Fixed large output");
assert!(!is_error);
assert_eq!(*disposition, agen::ToolResultDisposition::Success);
assert!(content.len() <= 256, "{content}");
assert!(content.starts_with("useful output line\n"), "{content}");
assert!(content.contains("[truncated:"), "{content}");
assert!(content.contains("refine your query]"), "{content}");
}
other => panic!("unexpected model-visible result: {other:?}"),
}
assert!(matches!(
live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
let (snapshot, _) = alerter.subscribe_with_snapshot();
assert!(snapshot.is_empty(), "unexpected alerts: {snapshot:?}");
}
#[tokio::test]
async fn unsupported_early_dispatch_warning_reaches_live_and_buffered_engine_alerts() {
let mut engine = Engine::new(ScriptedClient::new(vec![finished_response()]));
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete);
let (tx, _) = broadcast::channel(8);
let alerter = Alerter::new(tx);
let (_, mut live) = alerter.subscribe_with_snapshot();
wire_engine_warnings(&mut engine, &alerter);
let output = engine.run(&mut History::new(), "Finish").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let alert = match live.try_recv() {
Ok(Event::Alert(alert)) => alert,
other => panic!("expected live Engine warning: {other:?}"),
};
assert_eq!(alert.level, AlertLevel::Warn);
assert_eq!(alert.source, AlertSource::Engine);
assert!(
alert.message.contains(
"provider does not expose a trustworthy per-call completion boundary"
),
"{}",
alert.message
);
assert!(matches!(
live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
let (snapshot, mut late_live) = alerter.subscribe_with_snapshot();
assert_eq!(snapshot.len(), 1, "unexpected alerts: {snapshot:?}");
assert_eq!(snapshot[0].level, alert.level);
assert_eq!(snapshot[0].source, alert.source);
assert_eq!(snapshot[0].message, alert.message);
assert_eq!(snapshot[0].timestamp_ms, alert.timestamp_ms);
assert!(matches!(
late_live.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
}
}
#[test]
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
let production = include_str!("controller.rs")
@@ -875,7 +875,7 @@ impl WorkerOperation {
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
}
Self::Remove => {
"Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
}
}
}
@@ -41,13 +41,12 @@ fn workflow_instruction() -> FeatureInstructionDeclaration {
.expect("static Merge Request workflow instruction declaration is valid")
}
const ALL_KINDS: [Kind; 6] = [
const ALL_KINDS: [Kind; 5] = [
Kind::Show,
Kind::Open,
Kind::Review,
Kind::Readiness,
Kind::Complete,
Kind::CompleteTicket,
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -56,7 +55,6 @@ enum Kind {
Readiness,
Open,
Complete,
CompleteTicket,
Review,
}
#[derive(Clone)]
@@ -98,14 +96,6 @@ struct CompleteMergeRequestInput {
resolution: MergeResolutionInput,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CompleteTicketInput {
ticket: String,
operation_id: String,
item_revision: String,
merge_request_ids: Vec<String>,
requirement_approval_event_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum MergeStrategyInput {
FastForward,
@@ -150,7 +140,7 @@ impl Kind {
Self::Open => config.open,
Self::Review => config.review,
Self::Readiness => config.readiness_check,
Self::Complete | Self::CompleteTicket => config.complete,
Self::Complete => config.complete,
}
}
@@ -160,7 +150,6 @@ impl Kind {
Self::Readiness => "CheckMergeRequestReadiness",
Self::Open => "OpenMergeRequest",
Self::Complete => "CompleteMergeRequest",
Self::CompleteTicket => "CompleteTicket",
Self::Review => "ReviewMergeRequest",
}
}
@@ -170,16 +159,12 @@ impl Kind {
Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
}
}
fn mutating(self) -> bool {
matches!(
self,
Self::Open | Self::Review | Self::Complete | Self::CompleteTicket
)
matches!(self, Self::Open | Self::Review | Self::Complete)
}
}
#[async_trait]
@@ -240,23 +225,6 @@ impl Tool for MergeRequestTool {
),
)
}
Kind::CompleteTicket => {
let v: CompleteTicketInput = parse(input)?;
nonempty_named("ticket", &v.ticket)?;
(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{ws}/tickets/{}/complete",
encode_path_segment(&v.ticket)
),
Some(json!({
"operation_id": v.operation_id,
"item_revision": v.item_revision,
"merge_request_ids": v.merge_request_ids,
"requirement_approval_event_id": v.requirement_approval_event_id,
})),
)
}
Kind::Review => {
let v: ReviewMergeRequestInput = parse(input)?;
let ctx = self.client.reviewer_context().ok_or_else(|| {
@@ -499,11 +467,6 @@ fn native_merge_request_operation(kind: Kind) -> NativeMergeRequestOperation {
surface: NativeMergeRequestSurface::Item,
identity_field: Some("merge_request_id"),
},
Kind::CompleteTicket => NativeMergeRequestOperation {
operation: "complete_ticket",
surface: NativeMergeRequestSurface::Collection,
identity_field: None,
},
}
}
@@ -584,8 +547,8 @@ pub fn mount_workspace_http_merge_request_wip(
let collection_descriptor = merge_request_descriptor(
&collection_tools,
"Open Merge Requests and complete Tickets through existing scoped authority",
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion retains its full guarded result-set inputs.",
"Open Merge Requests through existing scoped authority",
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion is a separate Ticket operation.",
)?;
registry.mount(WipProjection {
route: collection_route.clone(),
@@ -617,7 +580,7 @@ pub fn mount_workspace_http_merge_request_wip(
let item_descriptor = merge_request_descriptor(
&item_tools,
"Inspect and operate on the Merge Request bound to this object route",
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, Ticket result snapshot, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
)?;
registry.mount_dynamic(WipDynamicMount {
collection_route: collection_route.clone(),
@@ -665,18 +628,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
.get(operation)
.ok_or_else(merge_request_operation_not_found)?;
let input = native_merge_request_input(arguments, None, tool.projection.identity_field)?;
let affected_merge_requests = if tool.kind == Kind::CompleteTicket {
input
.get("merge_request_ids")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let output =
execute_native_merge_request_tool(tool, &self.permissions, input, None, context)
.await?;
@@ -699,18 +650,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
);
}
}
Kind::CompleteTicket => {
for reference in affected_merge_requests {
if is_merge_request_route_reference(&reference) {
record_merge_request_observation(
&self.revisions,
&reference,
&response,
true,
);
}
}
}
_ => {}
}
Ok(WipOperationOutput::native(
@@ -1148,9 +1087,6 @@ pub fn description(n: &str) -> Option<&'static str> {
"CompleteMergeRequest" => Some(
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
),
"CompleteTicket" => Some(
"Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this releases the current assignment atomically.",
),
"ReviewMergeRequest" => Some(
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
),
@@ -1220,7 +1156,6 @@ mod tests {
"CheckMergeRequestReadiness",
"OpenMergeRequest",
"CompleteMergeRequest",
"CompleteTicket",
"ReviewMergeRequest",
] {
assert!(description(name).is_some(), "missing operation {name}");
@@ -1369,8 +1304,7 @@ mod tests {
[
"ShowMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
"CompleteTicket"
"CompleteMergeRequest"
]
);
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
@@ -1427,7 +1361,6 @@ mod tests {
(NativeMergeRequestSurface::Item, "read"),
(NativeMergeRequestSurface::Item, "check_readiness"),
(NativeMergeRequestSurface::Item, "complete"),
(NativeMergeRequestSurface::Collection, "complete_ticket"),
]
);
@@ -1,5 +1,6 @@
use serde::Serialize;
use serde_json::{Map, Value};
use server_api::TicketSourceRefObservation;
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketQueryResponse {
@@ -168,17 +169,22 @@ struct ModelMergeRequest {
selector_to: String,
review_status: String,
subject_ref: Option<String>,
source_ref_observation: TicketSourceRefObservation,
integration_evidence_error: Option<String>,
review_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
/// MR evidence is inapplicable without a linked MR, independent of Ticket decisions.
integration_applicable: bool,
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
has_commit: bool,
review_status: Option<String>,
approved_current_subject: bool,
review_after_rescope: bool,
unresolved_request_changes: bool,
complete_for_integration: bool,
missing: Vec<String>,
@@ -517,23 +523,47 @@ fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
selector_to: string_field(merge, "selector_to")?,
review_status: string_field(merge, "review_status")?,
subject_ref: optional_string(merge, "current_subject_ref")?,
source_ref_observation: serde_json::from_value(
merge
.get("source_ref_observation")
.ok_or_else(|| "missing source_ref_observation".to_string())?
.clone(),
)
.map_err(|error| format!("invalid source_ref_observation: {error}"))?,
integration_evidence_error: optional_string(merge, "integration_evidence_error")?,
review_excerpt: optional_string(merge, "review_excerpt")?,
})
}
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
let evidence = object(value, "Ticket evidence")?;
Ok(ModelTicketEvidence {
has_merge_request: bool_field(evidence, "has_merge_request")?,
let has_merge_request = bool_field(evidence, "has_merge_request")?;
let projected = ModelTicketEvidence {
integration_applicable: has_merge_request,
has_merge_request,
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
has_review_request: bool_field(evidence, "has_review_request")?,
has_commit: bool_field(evidence, "has_commit")?,
review_status: optional_string(evidence, "review_status")?,
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
review_after_rescope: bool_field(evidence, "review_after_rescope")?,
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
missing: string_array(evidence, "missing")?,
})
};
if !has_merge_request {
// Empty MR sets are neither missing evidence nor vacuous approval.
return Ok(ModelTicketEvidence {
review_status: None,
approved_current_subject: false,
review_after_rescope: false,
unresolved_request_changes: false,
complete_for_integration: false,
missing: Vec::new(),
..projected
});
}
Ok(projected)
}
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
@@ -664,6 +694,38 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision",
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
"evidence": {
"has_merge_request": false, "has_current_subject_ref": false, "has_review_request": false, "has_commit": false,
"review_status": "approved", "approved_current_subject": true, "review_after_rescope": true,
"unresolved_request_changes": false, "complete_for_integration": true, "missing": ["merge_request"]
}
})).unwrap();
let projected = serde_json::to_value(projected).unwrap();
assert_eq!(projected["state"], state);
assert_eq!(projected["thread"][0]["kind"], "completed");
assert_eq!(projected["evidence"]["integration_applicable"], false);
assert_eq!(projected["evidence"]["review_status"], Value::Null);
assert_eq!(projected["evidence"]["approved_current_subject"], false);
assert_eq!(projected["evidence"]["complete_for_integration"], false);
assert_eq!(projected["evidence"]["missing"], json!([]));
}
}
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
@@ -735,6 +797,7 @@ mod tests {
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"review_after_rescope": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
@@ -817,6 +880,7 @@ mod tests {
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"review_after_rescope": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
@@ -844,6 +908,123 @@ mod tests {
assert!(!projected.to_string().contains("internal-blocker"));
}
#[test]
fn ticket_projection_preserves_source_diagnostics_separately_from_rescope_attestation() {
for (state, subject_ref, observation, integration_error, attested) in [
(
"merged",
Some("immutable-approved-source"),
json!({"status": "not_required"}),
None,
true,
),
(
"open",
None,
json!({"status": "unavailable", "code": "source_ref_unavailable"}),
Some("source_ref_unavailable"),
true,
),
(
"open",
Some("observed-source"),
json!({"status": "observed"}),
None,
false,
),
] {
let merged = state == "merged";
let merge_request = json!({
"merge_request_id": "mr-source",
"repository_key": "repository",
"state": state,
"selector_from": "source-branch",
"selector_to": "main",
"review_status": "approved",
"current_subject_ref": subject_ref,
"source_ref_observation": observation,
"integration_evidence_error": integration_error,
"review_excerpt": "Reviewed source"
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"item_revision": "current-ticket-revision",
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
"events": [],
"linked_objectives": [],
"assignments": [],
"implementation_reports": [],
"merge_requests": [merge_request.clone()],
"merge_request": merge_request,
"evidence": {
"has_merge_request": true,
"has_current_subject_ref": subject_ref.is_some(),
"has_review_request": true,
"has_commit": subject_ref.is_some(),
"review_status": "approved",
"approved_current_subject": subject_ref.is_some(),
"review_after_rescope": attested,
"unresolved_request_changes": false,
"complete_for_integration": merged,
"missing": if subject_ref.is_none() {
vec!["current_subject_ref"]
} else {
vec![]
}
}
}))
.expect("source evidence must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
for merge in [&projected["merge_requests"][0], &projected["merge_request"]] {
assert_eq!(merge["subject_ref"], json!(subject_ref));
assert_eq!(merge["source_ref_observation"], observation);
assert_eq!(
merge.get("integration_evidence_error"),
Some(&json!(integration_error))
);
}
assert_eq!(projected["item_revision"], "current-ticket-revision");
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
}
}
#[test]
fn merge_request_projection_rejects_missing_or_invalid_source_observation() {
let merge_request = json!({
"merge_request_id": "mr-source",
"repository_key": "repository",
"state": "open",
"selector_to": "main",
"review_status": "none",
"current_subject_ref": null,
"integration_evidence_error": null
});
assert!(
project_merge_request(&merge_request)
.expect_err("source observation is required")
.contains("source_ref_observation")
);
for observation in [
Value::Null,
json!({"status": "unknown"}),
json!({"status": "unavailable"}),
json!({"status": "unavailable", "code": 42}),
json!({"status": "observed", "code": "unexpected"}),
] {
let mut merge_request = merge_request.clone();
merge_request["source_ref_observation"] = observation;
assert!(
project_merge_request(&merge_request)
.expect_err("invalid source observation must fail")
.contains("source_ref_observation")
);
}
}
#[test]
fn relation_projection_accepts_current_workspace_api_shapes() {
let outgoing = project_relation(
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Separate from `tracing` (which is for developer logs). Alerts
//! are short human-readable messages the Worker layer wants a client to
//! see — for example "compaction failed", "tool output truncated".
//! see — for example "compaction failed", "tool-call early dispatch deferred".
//!
//! Each alert is broadcast on the shared `Event` channel and
//! also appended to an in-memory buffer so that clients connecting
+16 -2
View File
@@ -1027,7 +1027,7 @@ mod tests {
}
#[test]
fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() {
fn builtin_orchestrator_cleanup_retains_terminal_responsibility_and_blocks_unfinished_work() {
let rendered = PromptCatalog::builtins_only()
.unwrap()
.render_name("role.orchestrator", Value::UNDEFINED)
@@ -1040,7 +1040,21 @@ mod tests {
assert!(rendered.contains("call `WorkerStop`"));
assert!(rendered.contains("call `WorkerRemove`"));
assert!(rendered.contains("only then call `WorkdirDelete`"));
assert!(rendered.contains("`CurrentAssignment` means unassign and reread"));
assert!(rendered.contains("must have no unfinished Ticket work"));
assert!(rendered.contains("does not require unassignment before cleanup"));
assert!(rendered.contains("`UnfinishedWork` (`unfinished_work` on the wire)"));
assert!(rendered.contains("requires explicit work end or reassignment"));
assert!(rendered.contains("idle or stopped status does not end unfinished work"));
for stale_policy in [
"no current Ticket assignment",
"unassign it through the available orchestration authority",
"`CurrentAssignment`",
] {
assert!(
!rendered.contains(stale_policy),
"stale cleanup policy: {stale_policy}"
);
}
assert!(!rendered.contains("{% include"));
}
}
+7 -51
View File
@@ -6119,17 +6119,6 @@ mod tests {
})
.to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"operation_id": "complete-ticket-1",
"ticket_id": "ticket-internal",
"item_revision": "ticket-rev-1",
"merge_request_ids": ["MR-1"],
"requirement_approval_event_id": "review-1"
})
.to_string(),
},
]));
let orchestrator_config = MergeRequestFeatureConfig {
readiness_check: true,
@@ -6202,51 +6191,22 @@ mod tests {
.inspect("yoi.merge-request/collection/v1".into(), false)
.await
.unwrap();
let before_ticket_completion = orchestrator
.host
.projection(&item)
.unwrap()
.object
.validator;
orchestrator
let rejected = orchestrator
.call(
collection.into(),
"yoi.merge-request/collection/v1".into(),
"complete_ticket".into(),
json!({
"ticket": "T-685",
"operation_id": "complete-ticket-1",
"item_revision": "ticket-rev-1",
"merge_request_ids": ["MR-1"],
"requirement_approval_event_id": "review-1"
}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_ne!(
before_ticket_completion,
orchestrator
.host
.projection(&item)
.unwrap()
.object
.validator,
"Ticket completion must stale every Merge Request in its exact result set"
);
let stale = orchestrator
.call(
item,
"yoi.merge-request/item/v1".into(),
"check_readiness".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.expect_err("affected Merge Request observation must be stale");
assert!(stale.to_string().contains("ValidatorMismatch"));
.expect_err("Ticket completion is not a Merge Request operation");
assert!(
rejected.to_string().contains("operation is not declared"),
"{rejected}"
);
let orchestrator_requests = orchestrator_client.requests();
assert_eq!(orchestrator_requests.len(), 3);
assert_eq!(orchestrator_requests.len(), 2);
assert_eq!(
orchestrator_requests[0].path,
"/api/w/workspace/merge-requests/MR-1/readiness"
@@ -6260,10 +6220,6 @@ mod tests {
assert_eq!(completion_body["approval_event_id"], "review-1");
assert_eq!(completion_body["target_ref_before"], "target-1");
assert_eq!(completion_body["target_ref_after"], "result-1");
assert_eq!(
orchestrator_requests[2].path,
"/api/w/workspace/tickets/T-685/complete"
);
}
#[tokio::test]
@@ -0,0 +1,100 @@
-- Responsibility is durable. A release only ends the operational authority of
-- this assignment identity; reopening a Ticket must not revive that authority.
CREATE TABLE ticket_assignment_work_releases (
workspace_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
released_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, assignment_id),
FOREIGN KEY (workspace_id, assignment_id)
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
);
CREATE VIEW ticket_active_worker_assignments AS
SELECT current.* FROM ticket_current_worker_assignments AS current
JOIN typed_tickets AS ticket
ON ticket.workspace_id = current.workspace_id AND ticket.ticket_id = current.ticket_id
WHERE ticket.workflow_state NOT IN ('done', 'closed')
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released
WHERE released.workspace_id = current.workspace_id
AND released.assignment_id = current.assignment_id);
CREATE TRIGGER ticket_terminal_releases_work
AFTER UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state IN ('done', 'closed')
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at)
FROM ticket_current_worker_assignments
WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id;
END;
CREATE TRIGGER ticket_terminal_assignment_releases_work_insert
AFTER INSERT ON ticket_current_worker_assignments
WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed'))
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
END;
CREATE TRIGGER ticket_terminal_assignment_releases_work_update
AFTER UPDATE ON ticket_current_worker_assignments
WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed'))
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
END;
CREATE TRIGGER ticket_active_worker_role_insert
BEFORE INSERT ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker'
AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed'))
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = NEW.workspace_id AND role = NEW.role
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND assignment_id != NEW.assignment_id)
BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
CREATE TRIGGER ticket_active_worker_role_update
BEFORE UPDATE ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker'
AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed'))
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = NEW.workspace_id AND role = NEW.role
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND assignment_id != OLD.assignment_id)
BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
-- Keep archived responsibilities valid after Worker removal, but never allow
-- newly installed identities to refer to a removed / removal-reserved Worker.
CREATE TRIGGER ticket_active_worker_principal_insert
BEFORE INSERT ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker' AND (
NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND state IN ('executing', 'failed', 'succeeded')))
BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
CREATE TRIGGER ticket_active_worker_principal_update
BEFORE UPDATE ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker' AND (
NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND state IN ('executing', 'failed', 'succeeded')))
BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete
BEFORE DELETE ON worker_registry
WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
AND worker_id = OLD.worker_id)
BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
CREATE TRIGGER ticket_unfinished_work_blocks_worker_move
BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry
WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
AND worker_id = OLD.worker_id)
BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
File diff suppressed because it is too large Load Diff
@@ -384,9 +384,6 @@ CREATE TABLE ticket_current_worker_assignments (
FOREIGN KEY (workspace_id, ticket_id)
REFERENCES typed_tickets(workspace_id, ticket_id)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, runtime_id, worker_id)
REFERENCES worker_registry(workspace_id, runtime_id, worker_id)
ON DELETE RESTRICT,
CHECK(
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
OR
@@ -1219,9 +1216,6 @@ CREATE INDEX ticket_current_principal_idx
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
WHERE role IN ('orchestrator', 'coder');
CREATE UNIQUE INDEX ticket_current_worker_role_idx
ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id)
WHERE principal_kind = 'worker';
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
+2 -15
View File
@@ -75,18 +75,6 @@ impl RepositoryRegistryReader {
}
pub fn list(&self) -> RepositoryListProjection {
if self.repositories.is_empty() {
return RepositoryListProjection {
items: Vec::new(),
diagnostics: vec![Diagnostic {
severity: DiagnosticSeverity::Warning,
code: "repository_config_empty".to_string(),
message: "No repositories are configured for this workspace backend."
.to_string(),
}],
};
}
RepositoryListProjection {
items: self
.repositories
@@ -598,12 +586,11 @@ mod tests {
}
#[test]
fn empty_registry_reports_diagnostic_without_implicit_repository() {
fn empty_registry_is_valid_without_warning_or_implicit_repository() {
let projection = RepositoryRegistryReader::new(Vec::new()).list();
assert!(projection.items.is_empty());
assert_eq!(projection.diagnostics.len(), 1);
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
assert!(projection.diagnostics.is_empty());
}
#[test]
+406 -26
View File
@@ -69,7 +69,7 @@ pub struct WorkerRemovalPlanRequest {
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkerRemovalBlocker {
Hold,
CurrentAssignment {
UnfinishedWork {
assignment_id: String,
ticket_id: String,
},
@@ -229,8 +229,8 @@ impl SqliteWorkspaceStore {
};
let mut blockers=Vec::new();
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id 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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id});
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
}
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
@@ -276,10 +276,10 @@ impl SqliteWorkspaceStore {
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
return Err(stale_error(&plan,"hold added"));
}
let assigned:bool=tx.query_row("SELECT 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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
if assigned{
mark_stale(&tx,&plan,"current assignment added")?;tx.commit()?;
return Err(stale_error(&plan,"current assignment added"));
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
return Err(stale_error(&plan,"unfinished work added"));
}
let now=Utc::now().to_rfc3339();
tx.execute("UPDATE worker_removal_operations SET state='executing',updated_at=?1 WHERE operation_id=?2",params![now,plan.operation_id])?;
@@ -462,8 +462,8 @@ impl SqliteWorkspaceStore {
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
let assigned:bool=tx.query_row("SELECT 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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:current assignment added",plan.plan_id))); }
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_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 WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
let now=Utc::now().to_rfc3339();
if let Some(a)=&result.archive{
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
@@ -1427,29 +1427,60 @@ mod tests {
).map_err(StoreError::from)).unwrap();
assert_eq!(historical, 1);
}
fn install_current_assignment(
s: &SqliteWorkspaceStore,
assignment_id: &str,
) -> crate::Result<()> {
let assignment = TicketCoderAssignmentRecord {
workspace_id: "w".into(),
ticket_id: "ticket".into(),
assignment_id: assignment_id.into(),
worker: req().worker,
assigned_by: "test".into(),
assigned_at: "t".into(),
};
s.set_current_ticket_coder_assignment(
&assignment,
None,
&format!("event-{assignment_id}"),
&format!("operation-{assignment_id}"),
false,
)
.map(|_| ())
}
fn seed_current_assignment(s: &SqliteWorkspaceStore) {
install_current_assignment(s, "assignment").unwrap();
}
fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET workflow_state=?1
WHERE workspace_id='w' AND ticket_id='ticket'",
[terminal_state],
)?;
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
Ok(())
})
.unwrap();
store
}
#[test]
fn assignment_and_orphan_are_authoritative() {
let s = setup();
s.with_conn(|c| {
let stable_worker_id = worker_id().to_string();
c.execute(
"INSERT INTO ticket_worker_assignments(\
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at\
) VALUES('w','ticket','assignment','r',?1,'test','t')",
[&stable_worker_id],
)?;
c.execute(
"INSERT INTO ticket_current_worker_assignments(\
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at\
) VALUES('w','ticket','assignment','r',?1,'t')",
[&stable_worker_id],
)?;
Ok(())
})
.unwrap();
seed_current_assignment(&s);
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
assert!(
matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
matches!(&p.blockers[..],[WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
);
let runtime_only = WorkerRetentionInventory {
workspace_id: "w".into(),
@@ -1501,6 +1532,355 @@ mod tests {
.unwrap();
assert_eq!(unchanged, 2);
}
#[test]
fn unfinished_work_blocker_uses_explicit_wire_kind() {
let wire = serde_json::json!({
"kind": "unfinished_work",
"assignment_id": "assignment",
"ticket_id": "ticket",
});
let blocker: WorkerRemovalBlocker = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(blocker).unwrap(), wire);
}
#[test]
fn terminal_retained_assignment_allows_worker_removal_even_after_reopen() {
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
for reopen in [false, true] {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status=?1, workflow_state=?2
WHERE workspace_id='w' AND ticket_id='ticket'",
params![status, workflow_state],
)?;
if reopen {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='planning'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
}
Ok(())
})
.unwrap();
store
.update_worker_retention_policy(
"w",
1,
&WorkerRetentionPolicyUpdate {
policy_id: "purge".into(),
session_disposition: SessionDisposition::Purge,
metadata_disposition: MetadataDisposition::Tombstone,
archive_retention: ArchiveRetention::Forever,
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
},
)
.unwrap();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(
plan.blockers.is_empty(),
"{status}/{workflow_state}, reopened={reopen}"
);
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
archive: None,
source_removed: true,
diagnostics_retained: false,
};
let committed = store
.commit_worker_removal(
"w",
&plan.operation_id,
&plan.input_fingerprint,
&result,
)
.unwrap();
assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_none()
);
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some());
let (retained, historical): (i64, i64) = store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
WHERE workspace_id='w' AND assignment_id='assignment'",
[],
|row| row.get(0),
)?,
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments
WHERE workspace_id='w' AND assignment_id='assignment'",
[],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!(
(retained, historical),
(1, 1),
"assignment history must survive removal"
);
}
}
}
#[test]
fn unfinished_work_added_after_planning_blocks_preparation() {
let store = setup();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
seed_current_assignment(&store);
let error = store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
if reason == "unfinished work added")
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
}
#[test]
fn distinct_assignment_after_terminal_end_blocks_preparation() {
for terminal_state in ["done", "closed"] {
let store = reopened_terminal_assignment_store(terminal_state);
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(plan.blockers.is_empty());
install_current_assignment(&store, "new-assignment").unwrap();
let blocked = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(
blocked.blockers,
vec![WorkerRemovalBlocker::UnfinishedWork {
assignment_id: "new-assignment".into(),
ticket_id: "ticket".into(),
}]
);
let replay = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(replay.plan_id, blocked.plan_id);
assert_eq!(replay.input_fingerprint, blocked.input_fingerprint);
assert_ne!(blocked.plan_id, plan.plan_id);
let error = store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
if reason == "unfinished work added")
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
}
}
#[test]
fn prepared_removal_fences_distinct_assignment_after_terminal_end() {
let store = reopened_terminal_assignment_store("done");
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
let error = install_current_assignment(&store, "new-assignment").unwrap_err();
assert!(
matches!(error, StoreError::TicketAssignmentConflict(message)
if message.contains("being retained or has been removed"))
);
let (current, history): (i64, i64) = store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
|row| row.get(0),
)?,
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments
WHERE workspace_id='w' AND assignment_id='new-assignment'",
[],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!(current, 1, "the ended responsibility must remain retained");
assert_eq!(history, 0, "the rejected new identity must roll back");
}
#[test]
fn commit_rechecks_distinct_assignment_independently_of_admission_fence() {
let store = reopened_terminal_assignment_store("done");
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
// Fault injection is confined to this in-memory authority fixture: use
// direct SQLite writes to bypass the public API removal fence, and drop
// both principal admission triggers to prove commit's independent guard.
store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute_batch(
"DROP TRIGGER ticket_active_worker_principal_insert;
DROP TRIGGER ticket_active_worker_principal_update;",
)?;
tx.execute(
"INSERT INTO ticket_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id,
assigned_by, assigned_at
) VALUES ('w', 'ticket', 'new-assignment', 'r', ?1, 'test', 'new')",
[worker_id().to_string()],
)?;
let replaced = tx.execute(
"UPDATE ticket_current_worker_assignments
SET assignment_id='new-assignment', updated_at='new'
WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder'
AND assignment_id='assignment'
AND EXISTS (
SELECT 1 FROM ticket_assignment_work_releases
WHERE workspace_id='w' AND assignment_id='assignment'
)",
[],
)?;
if replaced != 1 {
return Err(StoreError::InvalidInput(
"fault injection requires one ended current assignment".into(),
));
}
tx.commit()?;
Ok(())
})
.unwrap();
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
archive: None,
source_removed: true,
diagnostics_retained: false,
};
let error = store
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
if reason == "unfinished work added")
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_none());
let (state, active): (String, String) = store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT state FROM worker_removal_operations WHERE operation_id=?1",
[&plan.operation_id],
|row| row.get(0),
)?,
conn.query_row(
"SELECT assignment_id FROM ticket_active_worker_assignments
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!(
state, "executing",
"rejected commit must remain recoverable"
);
assert_eq!(
active, "new-assignment",
"the ended identity must not reactivate"
);
}
#[test]
fn terminal_assignment_does_not_bypass_hold_added_before_commit() {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET workflow_state='done'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
Ok(())
})
.unwrap();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_registry SET retention_state='pinned'
WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
[worker_id().to_string()],
)?;
Ok(())
})
.unwrap();
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
expected_worker_revision: plan.worker_revision.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
archive: None,
source_removed: true,
diagnostics_retained: false,
};
let error = store
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
if reason == "hold added")
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
}
#[test]
fn concurrent_plan_converges_and_purge_omits_tombstone() {
let s = std::sync::Arc::new(setup());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
// Included as a child of server::tests so the existing router/fixture helpers
// remain private. These are API regressions, not repository/provider tests:
// every authority record lives in a temporary database, with no Git checkout.
use super::*;
use merge_request::{
CompleteMergeRequest, ConflictResolution, MergeRequestAuth, MergeRequestReviewSubject,
MergeRequestStore, MergeStrategy, OpenMergeRequest, RegisterReviewerChildSession,
RequestMergeRequestReview, ReviewDecision, ReviewEvent, SubmitMergeRequestReview,
};
use rusqlite::{Connection, params};
use server_api::{
MergeRequestDetailResponse, MergeRequestListResponse, TicketDetail, TicketQueryResponse,
TicketSourceRefObservation,
};
const EVIDENCE_MR: &str = "ticket-evidence-mr";
const EVIDENCE_SOURCE: &str = "immutable-approved-source";
const EVIDENCE_TARGET: &str = "immutable-target-result";
// Deliberately freeze review dates: freshness must follow the exact item
// revision and source/result snapshot, not a comparison of wall-clock dates.
fn evidence_time() -> chrono::DateTime<Utc> {
"2000-01-01T00:00:00Z".parse().unwrap()
}
struct EvidenceApiFixture {
_temp: tempfile::TempDir,
api: WorkspaceApi,
app: Router,
backend: SqliteTicketBackend,
store: MergeRequestStore,
ticket_id: String,
auth: MergeRequestAuth,
}
impl EvidenceApiFixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
assert!(
api.config
.repositories
.iter()
.all(|repository| repository.path.is_none())
);
assert!(!temp.path().join(".git").exists());
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("T716 remote immutable evidence");
input.workflow_state = Some(TicketWorkflowState::InProgress);
input.targets = vec![ticket::TicketTarget {
repository_key: "test-repository".into(),
ref_selector: Some("develop".into()),
access: TicketTargetAccess::ReadWrite,
}];
let ticket_id = backend.create(input).unwrap().id;
// Registry/assignment authority only; there is no live Worker or Runtime
// capable of supplying an observation for this fixture.
let worker = RuntimeWorkerRef::new("evidence-offline-runtime", "evidence-coder");
api.store
.upsert_worker_registry(&WorkerRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
worker: worker.clone(),
display_name: "Offline evidence Coder".into(),
profile: Some("builtin:coder".into()),
retention_state: "normal".into(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: evidence_time().to_rfc3339(),
updated_at: evidence_time().to_rfc3339(),
})
.unwrap();
let assignment_id = "evidence-assignment".to_string();
api.store
.set_current_ticket_coder_assignment(
&TicketCoderAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket_id.clone(),
assignment_id: assignment_id.clone(),
worker: worker.clone(),
assigned_by: "fixture".into(),
assigned_at: evidence_time().to_rfc3339(),
},
None,
"evidence-assigned",
"evidence-assignment-operation",
false,
)
.unwrap();
let auth = MergeRequestAuth {
workspace_id: TEST_WORKSPACE_ID.into(),
repository_id: test_repository_id(&api),
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
assignment_id,
};
let store = merge_request_store(&api, TEST_WORKSPACE_ID).unwrap();
store
.open_merge_request(OpenMergeRequest {
merge_request_id: EVIDENCE_MR.into(),
ticket_id: ticket_id.clone(),
repository_id: auth.repository_id.clone(),
selector_from: "work/t716".into(),
selector_to: "develop".into(),
summary: String::new(),
auth: auth.clone(),
now: evidence_time(),
})
.unwrap();
let app = build_inner_router(api.clone());
Self {
_temp: temp,
api,
app,
backend,
store,
ticket_id,
auth,
}
}
fn revision(&self) -> String {
ticket_item_checker::item_revision(
&self.backend.show(self.ticket_id.clone().into()).unwrap(),
)
}
fn subjects(&self) -> Vec<MergeRequestReviewSubject> {
vec![MergeRequestReviewSubject {
merge_request_id: EVIDENCE_MR.into(),
subject_ref: EVIDENCE_SOURCE.into(),
}]
}
fn approve(&self, token: &str) -> ReviewEvent {
let child_session_id = format!("evidence-reviewer-{token}");
self.store
.register_reviewer_child_session(RegisterReviewerChildSession {
workspace_id: TEST_WORKSPACE_ID.into(),
parent_runtime_id: self.auth.runtime_id.clone(),
parent_worker_id: self.auth.worker_id.clone(),
child_session_id: child_session_id.clone(),
reviewer_profile: "builtin:reviewer".into(),
now: evidence_time(),
})
.unwrap();
self.store
.request_review(RequestMergeRequestReview {
merge_request_id: EVIDENCE_MR.into(),
ticket_id: self.ticket_id.clone(),
ticket_item_revision: self.revision(),
ticket_merge_request_subjects: self.subjects(),
subject_ref: EVIDENCE_SOURCE.into(),
child_session_id,
capability_token: token.into(),
auth: self.auth.clone(),
now: evidence_time(),
})
.unwrap();
self.store
.submit_review(SubmitMergeRequestReview {
merge_request_id: EVIDENCE_MR.into(),
ticket_id: self.ticket_id.clone(),
current_subject_ref: EVIDENCE_SOURCE.into(),
capability_token: token.into(),
decision: ReviewDecision::Approve,
body: format!("Requirement approval {token}"),
findings: vec![],
now: evidence_time(),
})
.unwrap()
}
fn integrate(&self, approval: &ReviewEvent) -> merge_request::MergeEvent {
self.store
.complete(CompleteMergeRequest {
merge_request_id: EVIDENCE_MR.into(),
ticket_id: self.ticket_id.clone(),
operation_id: "evidence-integration".into(),
approval_event_id: approval.event_id.clone(),
current_subject_ref: EVIDENCE_SOURCE.into(),
target_ref_before: "target-before".into(),
target_ref_after: EVIDENCE_TARGET.into(),
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: self.auth.clone(),
now: evidence_time(),
})
.unwrap()
}
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
ticket::TicketCompletion {
operation_key: "evidence-ticket-completion".into(),
expected_item_revision: self.revision(),
expected_state: TicketWorkflowState::InProgress,
reason: "Implementation judged complete independently of MR attestation".into(),
references: Vec::new(),
author: Some("fixture".into()),
}
}
async fn show(&self) -> TicketDetail {
serde_json::from_value(
get_json(
self.app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", self.ticket_id),
)
.await,
)
.unwrap()
}
async fn query(&self, filter: Value) -> TicketQueryResponse {
serde_json::from_value(
request_json(
self.app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"),
Some(filter),
StatusCode::OK,
)
.await,
)
.unwrap()
}
async fn assert_filters(&self, approved: bool, missing: bool, stale: bool) {
for (filter, matches) in [
(json!({"evidence": ["approved_review"]}), approved),
(json!({"attention": ["missing_evidence"]}), missing),
(json!({"attention": ["stale_after_rescope"]}), stale),
] {
let result = self.query(filter.clone()).await;
let ids = result
.items
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>();
let expected = if matches {
vec![self.ticket_id.as_str()]
} else {
vec![]
};
assert_eq!(ids, expected, "{filter}");
if matches {
let shown = self.show().await;
assert_eq!(result.items[0].item_revision, shown.item_revision);
assert_eq!(result.items[0].evidence, shown.evidence);
assert_eq!(result.items[0].merge_requests, shown.merge_requests);
}
}
}
async fn list(&self) -> MergeRequestListResponse {
serde_json::from_value(
get_json(
self.app.clone(),
&format!(
"/api/w/{TEST_WORKSPACE_ID}/merge-requests?ticket_ref={}",
self.ticket_id,
),
)
.await,
)
.unwrap()
}
async fn detail(&self, after: u64, limit: usize) -> MergeRequestDetailResponse {
serde_json::from_value(
get_json(
self.app.clone(),
&format!(
"/api/w/{TEST_WORKSPACE_ID}/merge-requests/{EVIDENCE_MR}?after={after}&limit={limit}",
),
)
.await,
)
.unwrap()
}
fn rescope(&self) {
self.backend
.edit_item(
self.ticket_id.clone().into(),
TicketItemEdit {
body: Some(MarkdownText::new(
"Revised requirements require a fresh exact-revision approval.",
)),
..Default::default()
},
)
.unwrap();
}
}
#[tokio::test]
async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("integration");
let merge = fixture.integrate(&approval);
let revision = fixture.revision();
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&approval))
.unwrap();
assert_eq!(ticket::ticket_item_revision(&completion), revision);
assert!(
fixture
.api
.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_none()
);
assert!(
fixture
.api
.store
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_some()
);
let shown = fixture.show().await;
assert_eq!(shown.state, "done");
assert_eq!(shown.item_revision, revision);
assert!(
shown.evidence.complete_for_integration,
"{:?}",
shown.evidence.missing
);
assert!(shown.evidence.review_after_rescope);
assert!(shown.evidence.missing.is_empty());
assert_eq!(shown.merge_requests.len(), 1);
let summary = &shown.merge_requests[0];
assert_eq!(
summary.current_subject_ref.as_deref(),
Some(EVIDENCE_SOURCE)
);
assert_eq!(summary.review_subject_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(summary.review_status, "approved");
assert_eq!(
summary.source_ref_observation,
TicketSourceRefObservation::NotRequired {}
);
assert_eq!(summary.integration_evidence_error, None);
fixture.assert_filters(true, false, false).await;
let listed = fixture.list().await;
assert_eq!(listed.items.len(), 1);
assert_eq!(&listed.items[0].summary, summary);
assert!(listed.items[0].ref_diagnostics.is_empty());
assert_eq!(listed.items[0].thread_event_count, merge.sequence as usize);
// Neither page includes the MergeEvent. Ref evidence must be computed from
// the full stored thread before applying response-thread pagination.
let first = fixture.detail(0, 1).await;
assert_eq!(first.merge_request.thread.len(), 1);
assert!(matches!(
first.merge_request.thread[0],
server_api::MergeRequestThreadEvent::ReviewRequested(_)
));
let empty = fixture.detail(merge.sequence, 1).await;
assert!(empty.merge_request.thread.is_empty());
for detail in [first, empty] {
assert_eq!(detail.source.status, "known");
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.target.status, "known");
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339());
assert_eq!(detail.source.diagnostic, None);
assert_eq!(detail.target.diagnostic, None);
}
}
#[tokio::test]
async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_result_snapshot() {
let fixture = EvidenceApiFixture::new().await;
let integration = fixture.approve("integration");
let merge = fixture.integrate(&integration);
fixture.rescope();
let revision = fixture.revision();
assert_ne!(integration.ticket_item_revision, revision);
let stale = fixture.show().await;
assert!(stale.evidence.approved_current_subject); // integration proof survives
assert!(!stale.evidence.review_after_rescope);
assert!(!stale.evidence.complete_for_integration);
assert!(
stale
.evidence
.missing
.contains(&"review_after_rescope".into())
);
fixture.assert_filters(false, true, true).await;
let mut outdated_completion = fixture.completion(&integration);
outdated_completion.expected_item_revision = integration.ticket_item_revision.clone();
assert!(
fixture
.backend
.complete(&fixture.ticket_id, outdated_completion)
.is_err()
);
assert_eq!(fixture.show().await.state, "inprogress");
let latest = fixture.approve("latest-revision");
assert_eq!(latest.created_at, integration.created_at);
assert_eq!(latest.ticket_item_revision, revision);
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
let fresh = fixture.show().await;
assert!(
fresh.evidence.complete_for_integration,
"{:?}",
fresh.evidence.missing
);
assert_eq!(
fresh.merge_requests[0].review_excerpt.as_deref(),
Some("Requirement approval integration")
);
fixture.assert_filters(true, false, false).await;
// A current revision alone is insufficient: simulate a stored attestation
// against a different result set using typed serialized payloads in this
// temporary fixture database, never a live Workspace database.
let mut wrong_snapshot = latest.clone();
wrong_snapshot
.ticket_merge_request_subjects
.push(MergeRequestReviewSubject {
merge_request_id: "other-result".into(),
subject_ref: "other-approved-source".into(),
});
let mut requested = fixture
.store
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
.unwrap()
.thread
.into_iter()
.find_map(|event| match event {
merge_request::MergeRequestThreadEvent::ReviewRequested(event)
if event.event_id == latest.request_event_id =>
{
Some(event)
}
_ => None,
})
.unwrap();
requested.ticket_merge_request_subjects = wrong_snapshot.ticket_merge_request_subjects.clone();
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
for (event_id, payload) in [
(
&requested.event_id,
serde_json::to_string(&requested).unwrap(),
),
(
&wrong_snapshot.event_id,
serde_json::to_string(&wrong_snapshot).unwrap(),
),
] {
assert_eq!(connection.execute(
"UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3",
params![TEST_WORKSPACE_ID, EVIDENCE_MR, event_id, payload],
).unwrap(), 1);
}
drop(connection);
let wrong = fixture.show().await;
assert!(wrong.evidence.approved_current_subject);
assert!(!wrong.evidence.complete_for_integration);
fixture.assert_filters(false, true, true).await;
assert_eq!(fixture.show().await.state, "inprogress");
let repaired = fixture.approve("exact-snapshot");
fixture.assert_filters(true, false, false).await;
// The newest requirement approval must not replace the historical approval
// and source which actually authorized integration.
let stored = fixture
.store
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
.unwrap();
assert_eq!(
stored.integration_approval().unwrap().event_id,
integration.event_id
);
assert_eq!(stored.merged_result().unwrap(), &merge);
let detail = fixture.detail(repaired.sequence - 1, 1).await;
assert_eq!(detail.merge_request.thread.len(), 1);
assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE));
assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET));
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&repaired))
.unwrap();
assert_eq!(ticket::ticket_item_revision(&completion), revision);
assert_eq!(fixture.show().await.state, "done");
fixture.assert_filters(true, false, false).await;
}
#[tokio::test]
async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescope() {
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("open-review");
fixture.rescope();
// End this assignment identity's work without erasing its responsibility.
// A retained Coder must not silently supply a Runtime for an open source.
let connection = Connection::open(&fixture.api.config.database_path).unwrap();
assert_eq!(connection.execute(
"INSERT INTO ticket_assignment_work_releases(workspace_id,assignment_id,released_at) VALUES(?1,?2,?3)",
params![TEST_WORKSPACE_ID, fixture.auth.assignment_id, evidence_time().to_rfc3339()],
).unwrap(), 1);
drop(connection);
assert!(
fixture
.api
.store
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_some()
);
assert!(
fixture
.api
.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_none()
);
let shown = fixture.show().await;
let summary = &shown.merge_requests[0];
assert_eq!(summary.state, "open");
assert_eq!(summary.current_subject_ref, None);
assert_eq!(
summary.source_ref_observation,
TicketSourceRefObservation::Unavailable {
code: "source_ref_runtime_unavailable".into(),
}
);
assert_eq!(shown.evidence.review_status.as_deref(), Some("unknown"));
assert!(!shown.evidence.complete_for_integration);
assert!(
shown
.evidence
.missing
.contains(&"source_ref_unavailable".into())
);
assert!(
!shown
.evidence
.missing
.contains(&"review_after_rescope".into())
);
fixture.assert_filters(false, true, false).await;
let listed = fixture.list().await;
assert_eq!(listed.items.len(), 1);
assert_eq!(&listed.items[0].summary, summary);
assert_eq!(
listed.items[0].ref_diagnostics[0].code,
"source_ref_runtime_unavailable"
);
let detail = fixture.detail(approval.sequence, 1).await;
assert!(detail.merge_request.thread.is_empty());
assert_eq!(detail.source.status, "unknown");
assert_eq!(detail.source.revision_ref, None);
assert_eq!(
detail.source.diagnostic.unwrap().code,
"source_ref_runtime_unavailable"
);
assert_eq!(detail.target.status, "unknown");
assert_eq!(
detail.target.diagnostic.unwrap().code,
"target_ref_runtime_unavailable"
);
}
#[tokio::test]
async fn evidence_query_continues_bounded_scans_past_nonmatching_tickets() {
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("integration");
fixture.integrate(&approval);
for title in [
"A nonmatching one",
"B nonmatching two",
"C nonmatching three",
] {
fixture
.backend
.create(ticket::NewTicket::new(title))
.unwrap();
}
for filter in [
json!({"evidence": ["approved_review"]}),
json!({"review_status": "approved"}),
] {
let mut request = filter.clone();
request["sort"] = json!("title");
request["limit"] = json!(1);
let first = fixture.query(request.clone()).await;
assert!(first.items.is_empty());
assert!(first.page.has_more);
assert!(first.page.source_truncated);
request["cursor"] = json!(first.page.next_cursor.unwrap());
let second = fixture.query(request).await;
assert_eq!(second.items.len(), 1);
assert_eq!(second.items[0].id, fixture.ticket_id);
assert!(second.items[0].evidence.review_after_rescope);
assert!(!second.page.has_more);
}
}
#[test]
fn source_observation_diagnostics_preserve_provider_failure_codes() {
for (provider_code, expected) in [
("repository_ref_not_found", "source_ref_not_found"),
(
"repository_ref_provider_timeout",
"source_ref_provider_timeout",
),
(
"repository_ref_provider_unavailable",
"source_ref_provider_unavailable",
),
(
"repository_access_credential_expired",
"source_ref_credential_expired",
),
] {
let error = Error::RuntimeOperationFailed {
runtime_id: "fixture-runtime".into(),
code: provider_code.into(),
message: "Provider observation failed".into(),
};
assert_eq!(
merge_ref_diagnostic(remap_source_ref_error(error.into())).code,
expected
);
}
}
#[tokio::test]
async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_state() {
let fixture = EvidenceApiFixture::new().await;
let before = fixture
.store
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
.unwrap();
fixture
.backend
.update_state(
&fixture.ticket_id,
ticket::TicketStateUpdate {
operation_key: "close-with-open-mr".into(),
expected_item_revision: fixture.revision(),
expected_state: TicketWorkflowState::InProgress,
state: TicketWorkflowState::Closed,
reason: "The request was withdrawn; no integration or approval is asserted".into(),
references: Vec::new(),
author: Some("workspace-user".into()),
},
)
.unwrap();
let after = fixture
.store
.get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR)
.unwrap();
assert_eq!(
serde_json::to_value(after).unwrap(),
serde_json::to_value(before).unwrap()
);
let shown = fixture.show().await;
assert_eq!(shown.state, "closed");
assert!(!shown.evidence.approved_current_subject);
assert_eq!(shown.merge_requests[0].state, "open");
}
#[tokio::test]
async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Investigation with no repository"))
.unwrap();
let ticket = backend.show(reference.id.clone().into()).unwrap();
backend
.complete(
&reference.id,
ticket::TicketCompletion {
operation_key: "research-result".into(),
expected_item_revision: ticket::ticket_item_revision(&ticket),
expected_state: TicketWorkflowState::Planning,
reason: "Answer recorded in the Ticket thread".into(),
references: Vec::new(),
author: Some("workspace-user".into()),
},
)
.unwrap();
let app = build_inner_router(api);
let shown: TicketDetail = serde_json::from_value(
get_json(
app.clone(),
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id),
)
.await,
)
.unwrap();
assert_eq!(shown.state, "done");
assert!(!shown.evidence.has_merge_request);
assert!(!shown.evidence.approved_current_subject);
assert!(!shown.evidence.complete_for_integration);
assert!(shown.evidence.missing.is_empty());
assert!(shown.evidence.review_status.is_none());
for filter in [
json!({"attention":["missing_evidence"]}),
json!({"evidence":["approved_review"]}),
] {
let query: TicketQueryResponse = serde_json::from_value(
request_json(
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"),
Some(filter),
StatusCode::OK,
)
.await,
)
.unwrap();
assert!(query.items.is_empty());
}
}
#[tokio::test]
async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Transport decisions"))
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let app = build_inner_router(api);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
for suffix in ["workflow-state", "state-changes", "state-fields/state"] {
request_json(
app.clone(),
"POST",
&format!("{path}/{suffix}"),
Some(json!({
"from":"planning","to":"done","reason":"old unguarded request","body":""
})),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
}
request_json(
app.clone(),
"POST",
&format!("{path}/workflow/close"),
Some(json!("unguarded close")),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
let close = json!({
"operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&before),
"expected_state":"planning","reason":"No further work required","author":"spoofed-client-author"
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow/close"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(close.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let closed = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed);
let receipt = closed.events.last().unwrap();
assert_eq!(receipt.author.as_deref(), Some("workspace-user"));
assert_eq!(receipt.from.as_deref(), Some("planning"));
assert_eq!(
receipt.attributes.get("operation_key"),
Some(&"guarded-close".to_string())
);
let reopen = json!({
"operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&closed),
"expected_state":"closed","state":"planning","reason":"A new question arrived"
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow-state"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(reopen.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// Exact replay of the old close is a receipt lookup, not another close.
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow/close"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(close.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let reopened = backend.show(reference.id.into()).unwrap();
assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(reopened.events.len(), closed.events.len() + 1);
}
+552 -41
View File
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 81;
const LATEST_SCHEMA_VERSION: i64 = 82;
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
@@ -283,6 +283,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Backend Job immutable resource serialization",
apply: migrate_backend_job_resources_v80_to_v81,
},
Migration {
version: 82,
name: "Ticket responsibility and unfinished work separation",
apply: migrate_assignment_work_v81_to_v82,
},
];
#[derive(Clone, Copy)]
@@ -379,13 +384,13 @@ pub struct WorkspaceBootstrapRecord {
pub operation_key: String,
pub request_fingerprint: String,
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceBootstrapResult {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
pub config_revision: u64,
pub replayed: bool,
}
@@ -1853,6 +1858,26 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
workspace_id: &str,
operation_id: &str,
) -> Result<()>;
/// Operational authority/occupancy only. Terminal state releases these
/// identities durably; reopening does not reactivate retained responsibility.
fn list_active_ticket_role_assignments(
&self,
workspace_id: &str,
ticket_id: &str,
) -> Result<Vec<TicketRoleAssignmentRecord>>;
fn get_active_ticket_role_assignment_for_worker(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<Option<TicketRoleAssignmentRecord>>;
fn get_active_ticket_role_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
role: TicketAssignmentRole,
) -> Result<Option<TicketRoleAssignmentRecord>>;
/// Retained responsibility, including ended work and removed principals.
/// Use the active variants for admission, routing, authorization or removal.
fn list_current_ticket_role_assignments(
&self,
workspace_id: &str,
@@ -1907,6 +1932,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
occurred_at: &str,
reason: &str,
) -> Result<bool>;
fn get_active_ticket_worker_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
) -> Result<Option<TicketCoderAssignmentRecord>>;
fn get_current_ticket_coder_assignment(
&self,
workspace_id: &str,
@@ -4401,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
signing_identity: &WorkspaceSigningIdentityActivation,
identity_provisioning_operation_key: &str,
) -> Result<WorkspaceBootstrapResult> {
validate_repository_record_identity(&record.repository)?;
if let Some(repository) = &record.repository {
validate_repository_record_identity(repository)?;
if repository.workspace_id != record.workspace.workspace_id {
return Err(Error::Store(
"Initial Repository does not belong to the Workspace bootstrap".to_string(),
));
}
}
if signing_identity.workspace_id != record.workspace.workspace_id {
return Err(Error::Store(
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
@@ -4442,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
params![workspace_id],
read_workspace_record,
)?;
let repository = tx.query_row(
let repository = if let Some(initial) = &record.repository { tx.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace.workspace_id, record.repository.repository_key],
params![workspace.workspace_id, initial.repository_key],
read_repository_record,
)?;
).optional()? } else { None };
let persisted_identity = tx.query_row(
r#"SELECT workspace_id, key_id, algorithm, public_key,
public_key_fingerprint, private_material_ref, revision, state,
@@ -4536,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.workspace.updated_at,
],
)?;
if let Some(repository) = &record.repository {
tx.execute(
r#"INSERT INTO repositories (
workspace_id, repository_id, repository_key, kind, provider, uri,
@@ -4543,23 +4581,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![
record.repository.workspace_id,
record.repository.repository_id,
record.repository.repository_key,
record.repository.kind,
record.repository.provider,
record.repository.source.uri,
record.repository.source.kind.as_str(),
record.repository.source.uri,
record.repository.default_ref,
record.repository.source_revision,
record.repository.source_fingerprint,
record.repository.observed_status.as_str(),
record.repository.observed_at,
record.repository.created_at,
record.repository.updated_at,
repository.workspace_id,
repository.repository_id,
repository.repository_key,
repository.kind,
repository.provider,
repository.source.uri,
repository.source.kind.as_str(),
repository.source.uri,
repository.default_ref,
repository.source_revision,
repository.source_fingerprint,
repository.observed_status.as_str(),
repository.observed_at,
repository.created_at,
repository.updated_at,
],
)?;
}
}
if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() {
let state = crate::config_source::initial_state()?;
@@ -8026,6 +8065,69 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_active_ticket_role_assignments(
&self,
workspace_id: &str,
ticket_id: &str,
) -> Result<Vec<TicketRoleAssignmentRecord>> {
self.with_conn(|conn| {
let sql = active_ticket_role_assignment_select_sql(
"WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 \
ORDER BY current.role, a.assigned_at, a.assignment_id",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
params![workspace_id, ticket_id],
read_ticket_role_assignment_record,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
})
}
fn get_active_ticket_role_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
role: TicketAssignmentRole,
) -> Result<Option<TicketRoleAssignmentRecord>> {
self.with_conn(|conn| {
let sql = active_ticket_role_assignment_select_sql(
"WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \
ORDER BY a.assigned_at, a.assignment_id LIMIT 1",
);
Ok(conn
.query_row(
&sql,
params![workspace_id, ticket_id, role.as_str()],
read_ticket_role_assignment_record,
)
.optional()?)
})
}
fn get_active_ticket_role_assignment_for_worker(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<Option<TicketRoleAssignmentRecord>> {
self.with_conn(|conn| {
let sql = active_ticket_role_assignment_select_sql(
"WHERE current.workspace_id = ?1 \
AND current.principal_kind = 'worker' \
AND current.runtime_id = ?2 AND current.worker_id = ?3 \
ORDER BY a.assigned_at, a.assignment_id LIMIT 1",
);
Ok(conn
.query_row(
&sql,
params![workspace_id, worker.runtime_id, worker.worker_id],
read_ticket_role_assignment_record,
)
.optional()?)
})
}
fn list_current_ticket_role_assignments(
&self,
workspace_id: &str,
@@ -8203,12 +8305,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
Some(expected.clone())
} else if record.role.is_singleton() && !current.is_empty() {
return Err(Error::TicketAssignmentConflict(format!(
"Ticket `{}` already has an active `{}` assignment `{}`",
record.ticket_id,
record.role.as_str(),
current[0].assignment_id
)));
let active: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)",
params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?;
if active {
return Err(Error::TicketAssignmentConflict(format!(
"Ticket {} already has unfinished {} work; use explicit reassign",
record.ticket_id, record.role.as_str())));
}
Some(current[0].clone())
} else {
None
};
@@ -8434,7 +8539,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
));
}
let conflicting: i64 = tx.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
"SELECT COUNT(*) FROM ticket_active_worker_assignments
WHERE workspace_id = ?1 AND ticket_id = ?2
AND role IN ('orchestrator', 'coder')",
params![record.workspace_id, record.ticket_id],
@@ -8447,6 +8552,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
));
}
let previous: Option<String> = tx.query_row(
"SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'",
params![record.workspace_id, record.ticket_id], |row| row.get(0)).optional()?;
tx.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'",
params![record.workspace_id, record.ticket_id])?;
tx.execute(
"INSERT INTO ticket_assignment_operations (
workspace_id, operation_id, action, ticket_id, role, principal_kind,
@@ -8478,10 +8588,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
tx.execute(
"INSERT INTO ticket_worker_assignment_events (
workspace_id, ticket_id, role, event_id, action, assignment_id,
actor, created_at, operation_id
) VALUES (?1, ?2, 'coder', ?3, 'assigned', ?4, ?5, ?6, ?7)",
actor, created_at, operation_id, previous_assignment_id
) VALUES (?1, ?2, 'coder', ?3, ?8, ?4, ?5, ?6, ?7, ?9)",
params![record.workspace_id, record.ticket_id, event_id, record.assignment_id,
record.assigned_by, record.assigned_at, operation_id],
record.assigned_by, record.assigned_at, operation_id,
if previous.is_some() { "reassigned" } else { "assigned" }, previous],
)?;
let event_index: i64 = tx.query_row(
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events
@@ -8649,6 +8760,27 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn get_active_ticket_worker_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
) -> Result<Option<TicketCoderAssignmentRecord>> {
self.with_conn(|conn| {
conn.query_row(
current_ticket_worker_assignment_select_sql()
.replace(
"ticket_current_worker_assignments",
"ticket_active_worker_assignments",
)
.as_str(),
params![workspace_id, ticket_id],
read_ticket_worker_assignment_record,
)
.optional()
.map_err(Error::from)
})
}
fn get_current_ticket_coder_assignment(
&self,
workspace_id: &str,
@@ -8753,7 +8885,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
read_ticket_worker_assignment_record,
)
.optional()?;
if previous.is_some() && !allow_reassign {
let previous_active: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder')",
params![record.workspace_id, record.ticket_id], |row| row.get(0))?;
if previous_active && !allow_reassign {
return Err(Error::TicketAssignmentConflict(format!(
"Ticket {} is already assigned; use the explicit reassign operation",
record.ticket_id
@@ -8790,7 +8925,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.assigned_at,
],
)?;
let current_write = if allow_reassign {
let current_write = if previous.is_some() {
tx.execute(
r#"UPDATE ticket_current_worker_assignments
SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6
@@ -10633,7 +10768,7 @@ fn runtime_removal_preflight(
),
(
"active_worker_assignment",
"SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE runtime_id = ?1)",
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE runtime_id = ?1)",
),
(
"active_workdir",
@@ -11743,6 +11878,13 @@ fn read_ticket_role_assignment_by_id(
.optional()?)
}
fn active_ticket_role_assignment_select_sql(where_clause: &str) -> String {
ticket_role_assignment_select_sql(where_clause).replace(
"ticket_current_worker_assignments",
"ticket_active_worker_assignments",
)
}
fn ticket_role_assignment_select_sql(where_clause: &str) -> String {
format!(
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.role, \
@@ -11982,7 +12124,7 @@ fn read_assignment_operation(
fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: &str) -> Error {
if matches!(error, rusqlite::Error::SqliteFailure(_, _)) {
Error::TicketAssignmentConflict(format!(
"Ticket {ticket_id} or Worker {worker_id} already has a current assignment"
"Ticket {ticket_id} has a responsibility conflict or Worker {worker_id} has unfinished work"
))
} else {
Error::Sqlite(error)
@@ -15128,6 +15270,99 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 81 {
return Err(Error::Store(
"Ticket work separation requires schema 81".into(),
));
}
let foreign_keys =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
let legacy =
conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?;
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?;
let result = (|| {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
let dependents = {
let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let schema = include_str!("latest_schema.sql");
let start = schema
.find("CREATE TABLE ticket_current_worker_assignments (")
.ok_or_else(|| Error::Store("Missing assignment schema".into()))?;
let end = schema[start..]
.find("CREATE TABLE ticket_worker_assignment_events")
.ok_or_else(|| Error::Store("Missing assignment schema end".into()))?
+ start;
tx.execute_batch(&schema[start..end].replace(
"CREATE TABLE ticket_current_worker_assignments",
"CREATE TABLE ticket_current_worker_assignments_v82",
))?;
tx.execute_batch("INSERT INTO ticket_current_worker_assignments_v82 SELECT * FROM ticket_current_worker_assignments;
DROP TABLE ticket_current_worker_assignments;
ALTER TABLE ticket_current_worker_assignments_v82 RENAME TO ticket_current_worker_assignments;")?;
for sql in dependents {
tx.execute_batch(&sql)?;
}
tx.execute_batch(include_str!("assignment_work.sql"))?;
// Existing done/closed + current-assignment data is preserved, and a
// durable release prevents a later reopen from resurrecting old work.
tx.execute_batch("INSERT OR IGNORE INTO ticket_assignment_work_releases
SELECT current.workspace_id, current.assignment_id, COALESCE(ticket.updated_at, current.updated_at)
FROM ticket_current_worker_assignments current JOIN typed_tickets ticket
ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id
WHERE ticket.workflow_state IN ('done','closed');")?;
let legacy_plans = {
let mut statement =
tx.prepare("SELECT operation_id, blockers_json FROM worker_removal_operations")?;
let rows = statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
for (operation_id, encoded) in legacy_plans {
let mut blockers: Vec<serde_json::Value> = serde_json::from_str(&encoded)
.map_err(|error| Error::Store(format!("invalid removal blockers: {error}")))?;
let mut changed = false;
for blocker in &mut blockers {
if blocker.get("kind").and_then(serde_json::Value::as_str)
== Some("current_assignment")
{
blocker["kind"] = serde_json::Value::String("unfinished_work".into());
changed = true;
}
}
if changed {
let encoded = serde_json::to_string(&blockers)
.map_err(|error| Error::Store(error.to_string()))?;
// Historical blocked plans stay blocked; issued fingerprints
// and identities are immutable. Fresh planning rereads active work.
tx.execute(
"UPDATE worker_removal_operations SET blockers_json=?2 WHERE operation_id=?1",
params![operation_id, encoded],
)?;
}
}
let broken: i64 =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if broken != 0 {
return Err(Error::Store(
"Ticket work migration foreign-key verification failed".into(),
));
}
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (82,'Ticket responsibility and unfinished work separation')", [])?;
tx.commit()?;
Ok(())
})();
conn.pragma_update(None, "legacy_alter_table", legacy)?;
conn.pragma_update(None, "foreign_keys", foreign_keys)?;
result
}
fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 80 {
return Err(Error::Store(
@@ -15371,6 +15606,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
));
"#,
)?;
conn.execute_batch(include_str!("assignment_work.sql"))?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -15403,6 +15639,32 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
)));
}
}
if current_schema_version(conn)? >= 82 {
for (kind, name) in [
("table", "ticket_assignment_work_releases"),
("view", "ticket_active_worker_assignments"),
("trigger", "ticket_terminal_releases_work"),
("trigger", "ticket_terminal_assignment_releases_work_insert"),
("trigger", "ticket_terminal_assignment_releases_work_update"),
("trigger", "ticket_active_worker_role_insert"),
("trigger", "ticket_active_worker_role_update"),
("trigger", "ticket_active_worker_principal_insert"),
("trigger", "ticket_active_worker_principal_update"),
("trigger", "ticket_unfinished_work_blocks_worker_delete"),
("trigger", "ticket_unfinished_work_blocks_worker_move"),
] {
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
params![kind, name],
|row| row.get(0),
)?;
if !exists {
return Err(Error::Store(format!(
"unfinished Ticket work constraint {name} is missing"
)));
}
}
}
Ok(())
}
@@ -17212,6 +17474,9 @@ mod tests {
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.with_conn(|conn| {
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
downgrade_assignment_work_schema(conn);
conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")?;
downgrade_schema_66_ticket_and_workdir_authority(conn)?;
conn.execute_batch(
"DROP TRIGGER runtime_binding_insert_blocked_by_removal; \
@@ -17281,6 +17546,11 @@ mod tests {
drop(SqliteWorkspaceStore::open(&path).unwrap());
{
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
.unwrap();
downgrade_assignment_work_schema(&conn);
conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")
.unwrap();
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
conn.execute_batch(
"ALTER TABLE worker_removal_operations \
@@ -17659,6 +17929,44 @@ mod tests {
.unwrap();
}
fn downgrade_assignment_work_schema(conn: &Connection) {
conn.execute_batch(
r#"
DROP TRIGGER ticket_terminal_releases_work;
DROP TRIGGER ticket_terminal_assignment_releases_work_insert;
DROP TRIGGER ticket_terminal_assignment_releases_work_update;
DROP TRIGGER ticket_active_worker_role_insert;
DROP TRIGGER ticket_active_worker_role_update;
DROP TRIGGER ticket_active_worker_principal_insert;
DROP TRIGGER ticket_active_worker_principal_update;
DROP TRIGGER ticket_unfinished_work_blocks_worker_delete;
DROP TRIGGER ticket_unfinished_work_blocks_worker_move;
DROP VIEW ticket_active_worker_assignments;
DROP TABLE ticket_assignment_work_releases;
CREATE UNIQUE INDEX ticket_current_worker_role_idx
ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id)
WHERE principal_kind='worker';
"#,
)
.unwrap();
// Restore the actual old FK contract, not just the old unique index.
let dependents = {
let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL").unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let table: String = conn.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name='ticket_current_worker_assignments'", [], |row| row.get(0)).unwrap();
let old_table = table.replace("CREATE TABLE ticket_current_worker_assignments", "CREATE TABLE current_assignments_old")
.replace(" CHECK(\n", " FOREIGN KEY(workspace_id,runtime_id,worker_id) REFERENCES worker_registry(workspace_id,runtime_id,worker_id) ON DELETE RESTRICT,\n CHECK(\n");
conn.execute_batch(&old_table).unwrap();
conn.execute_batch("INSERT INTO current_assignments_old SELECT * FROM ticket_current_worker_assignments; DROP TABLE ticket_current_worker_assignments; ALTER TABLE current_assignments_old RENAME TO ticket_current_worker_assignments;").unwrap();
for sql in dependents {
conn.execute_batch(&sql).unwrap();
}
}
fn prepare_schema_v52(path: &Path) {
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
@@ -17667,6 +17975,7 @@ mod tests {
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
.unwrap();
create_latest_workspace_schema(&conn).unwrap();
downgrade_assignment_work_schema(&conn);
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
.unwrap();
@@ -17928,6 +18237,10 @@ mod tests {
version: 81,
name: "Backend Job immutable resource serialization".to_string()
},
WorkspaceSchemaMigrationStep {
version: 82,
name: "Ticket responsibility and unfinished work separation".to_string()
},
]
);
@@ -18019,6 +18332,7 @@ mod tests {
(79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()),
(80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()),
(81, "Backend Job immutable resource serialization".to_string()),
(82, "Ticket responsibility and unfinished work separation".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -18370,7 +18684,7 @@ mod tests {
.collect::<Vec<_>>(),
vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
73, 74, 75, 76, 77, 78, 79, 80, 81
73, 74, 75, 76, 77, 78, 79, 80, 81, 82
]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -18379,7 +18693,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 32);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 33);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
@@ -20473,6 +20787,203 @@ mod tests {
assert_eq!(pinned.definition.name, "coder-review");
}
// Equivalent to the reported closed T-12 + retained Coder data. The
// migration fixture is a real pre-cutover schema, not the live Workspace.
#[test]
fn schema_v81_terminal_responsibility_survives_migration_reopen_and_worker_deletion() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
prepare_schema_v50(&path, Some("workspace-a"));
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
for migration in MIGRATIONS.iter().filter(|m| m.version <= 81) {
(migration.apply)(&conn).unwrap();
}
conn.execute_batch(r#"
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
VALUES('workspace-a','runtime-a','old-coder','Coder T-12','normal','1','1');
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at)
VALUES('workspace-a','closed-ticket','closed-ticket','Closed Ticket','closed','task','normal','','closed',1,'2');
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at)
VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','actor','1');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at)
VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','1');
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,action,assignment_id,actor,created_at)
VALUES('workspace-a','closed-ticket','assigned-event','assigned','old-assignment','actor','1');
"#).unwrap();
let legacy_blockers = serde_json::json!([
{"kind":"hold"}, {"kind":"current_assignment","assignment_id":"current_assignment","ticket_id":"closed-ticket"}
]).to_string();
conn.execute("INSERT INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,diagnostics_disposition,blockers_json,state,reason,created_at,updated_at)
VALUES('old-op','old-plan','immutable-fingerprint','workspace-a','runtime-a','old-coder','1','workspace-default-conservative',1,'purge','tombstone','forever','purge',?1,'blocked','retain','1','1')", [&legacy_blockers]).unwrap();
migrate_assignment_work_v81_to_v82(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 82);
apply_migrations(&conn).unwrap(); // canonical retry is a no-op
let (plan, fingerprint, state, encoded): (String,String,String,String) = conn.query_row(
"SELECT plan_id,input_fingerprint,state,blockers_json FROM worker_removal_operations WHERE operation_id='old-op'", [],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
assert_eq!(
(plan.as_str(), fingerprint.as_str(), state.as_str()),
("old-plan", "immutable-fingerprint", "blocked")
);
let blockers: Vec<crate::retention::WorkerRemovalBlocker> =
serde_json::from_str(&encoded).unwrap();
assert!(
matches!(&blockers[..], [crate::retention::WorkerRemovalBlocker::Hold,crate::retention::WorkerRemovalBlocker::UnfinishedWork { assignment_id,ticket_id }] if assignment_id=="current_assignment" && ticket_id=="closed-ticket")
);
conn.execute_batch(
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='closed-ticket';
DELETE FROM worker_registry WHERE worker_id='old-coder';",
)
.unwrap();
let (responsibility, active, history, tombstone): (i64, i64, i64, i64) = conn
.query_row(
"SELECT (SELECT COUNT(*) FROM ticket_current_worker_assignments),
(SELECT COUNT(*) FROM ticket_active_worker_assignments),
(SELECT COUNT(*) FROM ticket_worker_assignment_events),
(SELECT COUNT(*) FROM ticket_assignment_worker_tombstones)",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!((responsibility, active, history, tombstone), (1, 0, 1, 1));
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
0
);
assert!(
conn.execute(
"UPDATE ticket_current_worker_assignments SET updated_at='3'",
[]
)
.is_err(),
"removed Worker cannot receive new authority"
);
}
#[tokio::test]
async fn close_retains_responsibility_without_occupying_new_work_and_reopen_requires_new_identity()
{
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "w".into(),
owner_account_id: "owner-account".into(),
display_name: "W".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','coder','Coder','normal','1','1');")?;
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
let first = backend.create(ticket::NewTicket::new("First")).unwrap();
let second = backend.create(ticket::NewTicket::new("Second")).unwrap();
let assignment = TicketCoderAssignmentRecord {
workspace_id: "w".into(),
ticket_id: first.id.clone(),
assignment_id: "a1".into(),
worker: RuntimeWorkerRef::new("r", "coder"),
assigned_by: "actor".into(),
assigned_at: "1".into(),
};
store
.set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false)
.unwrap();
let next = TicketCoderAssignmentRecord {
ticket_id: second.id.clone(),
assignment_id: "a2".into(),
..assignment.clone()
};
assert!(
store
.set_current_ticket_coder_assignment(&next, None, "e2", "op2", false)
.is_err()
);
backend
.close(
first.id.clone().into(),
ticket::MarkdownText::new("Closed without integration proof"),
)
.unwrap();
assert_eq!(
store
.get_current_ticket_coder_assignment("w", &first.id)
.unwrap(),
Some(assignment.clone())
);
assert!(
store
.get_active_ticket_worker_assignment("w", &first.id)
.unwrap()
.is_none()
);
store
.set_current_ticket_coder_assignment(&next, None, "e2", "op2", false)
.unwrap();
// Generic Ticket tools do not expose terminal -> planning. Seed the
// authority state boundary to prove future/reconciled reopen is inert.
store.with_conn(|conn| { conn.execute("UPDATE typed_tickets SET workflow_state='planning' WHERE workspace_id='w' AND ticket_id=?1", [&first.id])?; Ok(()) }).unwrap();
assert!(
store
.get_active_ticket_worker_assignment("w", &first.id)
.unwrap()
.is_none()
);
// Replay of the original operation reports its identity but does not revive work.
store
.set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false)
.unwrap();
assert!(
store
.get_active_ticket_worker_assignment("w", &first.id)
.unwrap()
.is_none()
);
let restarted = TicketCoderAssignmentRecord {
assignment_id: "a3".into(),
..assignment.clone()
};
assert!(
store
.set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false)
.is_err(),
"another Ticket still occupies the Worker"
);
backend
.close(second.id.clone().into(), ticket::MarkdownText::new("Ended"))
.unwrap();
store
.set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false)
.unwrap();
assert_eq!(
store
.get_active_ticket_worker_assignment("w", &first.id)
.unwrap(),
Some(restarted)
);
let events = store
.list_ticket_coder_assignment_events("w", &first.id, 10)
.unwrap();
assert_eq!(events[0].previous_assignment_id.as_deref(), Some("a1"));
assert_eq!(events[0].action, "reassigned");
assert!(
store
.delete_worker_registry("w", &assignment.worker)
.is_err()
);
}
#[tokio::test]
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
let dir = tempfile::tempdir().unwrap();
@@ -21230,7 +21741,7 @@ INSERT INTO worker_registry (
operation_key: "invalid-key".to_string(),
request_fingerprint: "sha256:invalid-key".to_string(),
workspace: workspace.clone(),
repository,
repository: Some(repository),
},
&signing_identity,
"identity-store-test",
@@ -21269,7 +21780,7 @@ INSERT INTO worker_registry (
operation_key: "create-workspace".to_string(),
request_fingerprint: "sha256:create-workspace".to_string(),
workspace,
repository: valid_repository,
repository: Some(valid_repository),
};
store
.reserve_workspace_signing_identity_provisioning(
@@ -21296,7 +21807,7 @@ INSERT INTO worker_registry (
);
let mut duplicate = first;
duplicate.operation_key = "duplicate-workspace".to_string();
duplicate.repository.repository_id = Uuid::now_v7().to_string();
duplicate.repository.as_mut().unwrap().repository_id = Uuid::now_v7().to_string();
let error = store
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
.unwrap_err()
@@ -89,14 +89,7 @@ impl TicketItemCheckFindingCategory {
}
pub fn item_revision(ticket: &ticket::Ticket) -> String {
ticket
.events
.iter()
.rev()
.find(|event| matches!(event.kind.as_str(), "create" | "item_edit"))
.and_then(|event| event.attributes.get("event_id").cloned())
.or_else(|| ticket.meta.updated_at.clone())
.unwrap_or_else(|| format!("{}:0", ticket.meta.id))
ticket::ticket_item_revision(ticket)
}
pub fn request(
+155 -5
View File
@@ -356,10 +356,10 @@ impl SqliteWorkspaceStore {
params![operation.workspace_id, operation.working_directory_id],
|row| row.get(0),
)?;
let current_assignment: bool = conn.query_row(
let unfinished_work: bool = conn.query_row(
r#"SELECT EXISTS(
SELECT 1 FROM worker_workdir_links AS link
JOIN ticket_current_worker_assignments AS current
JOIN ticket_active_worker_assignments AS current
ON current.workspace_id=link.workspace_id
JOIN ticket_worker_assignments AS assignment
ON assignment.workspace_id=current.workspace_id
@@ -404,10 +404,10 @@ impl SqliteWorkspaceStore {
detail: "Workdir has a pending Worker attachment reservation",
});
}
if current_assignment {
if unfinished_work {
guards.push(WorkdirRemovalGuard {
category: "current_assignment",
detail: "Workdir is bound to a Worker with a current Ticket assignment",
category: "unfinished_work",
detail: "Workdir is bound to a Worker with unfinished Ticket work",
});
}
if retention_hold {
@@ -1196,6 +1196,156 @@ mod tests {
);
}
#[tokio::test]
async fn terminal_assignments_release_unfinished_work_guard_but_not_attachment_or_hold() {
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
let (store, workdir) = seeded_store().await;
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 ('workspace-a', 'runtime-a', 'worker-a', 'Worker A', 'pinned', '1', '1')",
[],
)?;
conn.execute(
"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at
) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'workdir-a', 'main', 'read_write', '1')",
[],
)?;
conn.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open',
'task', 'normal', '', 'inprogress', 1)",
[],
)?;
conn.execute(
"INSERT INTO ticket_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id,
assigned_by, assigned_at
) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', 'test', '1')",
[],
)?;
conn.execute(
"INSERT INTO ticket_current_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at
) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', '1')",
[],
)?;
Ok(())
})
.unwrap();
let intent =
workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
let categories = |guards: Vec<WorkdirRemovalGuard>| {
guards
.into_iter()
.map(|guard| guard.category)
.collect::<Vec<_>>()
};
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "unfinished_work", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status=?1, workflow_state=?2
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
params![status, workflow_state],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='planning'
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"],
"reopening must not restore unfinished work on the retained assignment"
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_some()
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_registry SET retention_state='normal'
WHERE workspace_id='workspace-a' AND runtime_id='runtime-a' AND worker_id='worker-a'",
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment"]
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_workdir_links SET unlinked_at='2'
WHERE workspace_id='workspace-a' AND workdir_id='workdir-a'",
[],
)?;
Ok(())
})
.unwrap();
assert!(store.workdir_removal_guards(&operation).unwrap().is_empty());
let completed = store.commit_workdir_removal_removed(&operation).unwrap();
assert_eq!(
completed.disposition,
Some(WorkdirRemovalDisposition::Removed)
);
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_none()
);
let retained: i64 = store
.with_conn(|conn| {
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'",
[],
|row| row.get(0),
)
.map_err(Error::from)
})
.unwrap();
assert_eq!(retained, 1);
}
}
#[tokio::test]
async fn retained_completion_keeps_registry_and_is_auditable() {
let (store, workdir) = seeded_store().await;
+345 -143
View File
@@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord,
pub repository: RepositoryRecord,
pub repository: Option<RepositoryRecord>,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
@@ -94,19 +94,23 @@ impl WorkspaceCatalogService {
)?;
let display_name =
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_source = validate_repository_source(&request.repository.uri)?;
let repository_uri = repository_source.uri.clone();
server_api::validate_repository_key(&request.repository.repository_key)
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
let repository_key = request.repository.repository_key.clone();
let default_ref = request
let repository_intent = request
.repository
.default_ref
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("HEAD")
.to_string();
.map(|repository| {
let source = validate_repository_source(&repository.uri)?;
server_api::validate_repository_key(&repository.repository_key).map_err(
|error| Error::InvalidInput(format!("invalid Repository key: {error}")),
)?;
let default_ref = repository
.default_ref
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("HEAD")
.to_string();
Ok::<_, Error>((repository.repository_key, source, default_ref))
})
.transpose()?;
let requested_workspace_id = requested_workspace_id
.map(|value| {
Uuid::parse_str(value.trim())
@@ -121,9 +125,9 @@ impl WorkspaceCatalogService {
requested_workspace_id.as_deref(),
&display_name,
Some(&owner_account_id),
&repository_key,
&repository_uri,
&default_ref,
repository_intent.as_ref().map(|(key, source, selector)| {
(key.as_str(), source.uri.as_str(), selector.as_str())
}),
);
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let (signing_identity, identity_provisioning_operation_key) =
@@ -146,21 +150,23 @@ impl WorkspaceCatalogService {
created_at: now.clone(),
updated_at: now.clone(),
},
repository: RepositoryRecord {
workspace_id,
repository_id: Uuid::now_v7().to_string(),
repository_key: repository_key.clone(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: repository_source.clone(),
default_ref: Some(default_ref),
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
},
repository: repository_intent.map(
|(repository_key, repository_source, default_ref)| RepositoryRecord {
workspace_id,
repository_id: Uuid::now_v7().to_string(),
repository_key: repository_key.clone(),
kind: "git".to_string(),
provider: Some("git".to_string()),
source: repository_source.clone(),
default_ref: Some(default_ref),
source_revision: 1,
source_fingerprint: repository_source_fingerprint(&repository_source),
observed_status: RepositoryObservedStatus::Unverified,
observed_at: None,
created_at: now.clone(),
updated_at: now,
},
),
},
&signing_identity,
&identity_provisioning_operation_key,
@@ -193,20 +199,19 @@ fn workspace_create_fingerprint(
requested_workspace_id: Option<&str>,
display_name: &str,
owner_account_id: Option<&str>,
repository_key: &str,
repository_uri: &str,
default_ref: &str,
repository: Option<(&str, &str, &str)>,
) -> String {
let payload = serde_json::json!({
"requested_workspace_id": requested_workspace_id,
"display_name": display_name,
"owner_account_id": owner_account_id,
"repository": {
"repository_key": repository_key,
"uri": repository_uri,
"default_ref": default_ref,
// Preserve repository-present fingerprint bytes for persisted create receipts.
"repository": repository.map(|(key, uri, selector)| serde_json::json!({
"repository_key": key,
"uri": uri,
"default_ref": selector,
"kind": "git",
}
}))
});
let mut hasher = Sha256::new();
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
@@ -294,11 +299,11 @@ mod tests {
let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "platform".to_string(),
default_ref: None,
},
}),
};
let owner_account_id = owner_account(store.as_ref());
@@ -354,11 +359,11 @@ mod tests {
let request = WorkspaceCreateRequest {
operation_key: operation_key.to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: repository_key.to_string(),
default_ref: None,
},
}),
};
let error = service
@@ -373,36 +378,37 @@ mod tests {
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
for with_repository in [false, true] {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: with_repository.then(|| InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
}),
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
@@ -413,55 +419,57 @@ mod tests {
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
for with_repository in [false, true] {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: with_repository.then(|| InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
}),
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
@@ -471,25 +479,26 @@ mod tests {
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
}
#[tokio::test]
@@ -506,11 +515,11 @@ mod tests {
let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
};
service
.create(request.clone(), owner_account_id.clone())
@@ -524,6 +533,199 @@ mod tests {
assert!(error.contains("different input"), "{error}");
}
#[test]
fn repository_free_create_initializes_authority_and_concurrent_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
);
let request = WorkspaceCreateRequest {
operation_key: "empty-concurrent".to_string(),
display_name: "No repositories".to_string(),
repository: None,
};
let barrier = Arc::new(std::sync::Barrier::new(4));
let handles = (0..4)
.map(|_| {
let (service, request, owner, barrier) = (
service.clone(),
request.clone(),
owner.clone(),
barrier.clone(),
);
std::thread::spawn(move || {
barrier.wait();
service.create(request, owner).unwrap()
})
})
.collect::<Vec<_>>();
let results = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>();
let id = &results[0].workspace.workspace_id;
assert_eq!(results.iter().filter(|result| !result.replayed).count(), 1);
assert!(
results
.iter()
.all(|result| &result.workspace.workspace_id == id && result.repository.is_none())
);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
assert!(store.list_repositories(id).unwrap().is_empty());
assert!(store.load_workspace_config(id).unwrap().is_some());
assert_eq!(
store
.get_workspace_signing_identity(id)
.unwrap()
.unwrap()
.state,
"active"
);
let counters = store.with_conn(|conn| {
let mut query = conn.prepare("SELECT resource_kind, next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 ORDER BY resource_kind")?;
Ok(query.query_map([id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?.collect::<std::result::Result<Vec<_>, _>>()?)
}).unwrap();
assert_eq!(
counters,
vec![
("objective".into(), 1),
("ticket".into(), 1),
("worker".into(), 1)
]
);
let mut different = request.clone();
different.repository = Some(InitialRepositoryIntent {
repository_key: "main".into(),
uri: "/runtime/repo".into(),
default_ref: None,
});
assert!(
service
.create(different, owner.clone())
.unwrap_err()
.to_string()
.contains("different input")
);
assert!(service.create(request, "missing-owner".into()).is_err());
assert!(store.list_repositories(id).unwrap().is_empty());
}
#[test]
fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
);
let request = WorkspaceCreateRequest {
operation_key: "current-repository".into(),
display_name: "Workspace".into(),
repository: Some(InitialRepositoryIntent {
repository_key: "main".into(),
uri: "/runtime/original".into(),
default_ref: None,
}),
};
let created = service.create(request.clone(), owner.clone()).unwrap();
let mut repository = created.repository.unwrap();
repository.source = validate_repository_source("https://example.test/changed.git").unwrap();
repository.source_revision += 1;
repository.source_fingerprint = repository_source_fingerprint(&repository.source);
store.with_conn(|conn| {
conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'",
rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?;
Ok(())
}).unwrap();
assert_eq!(
service
.create(request.clone(), owner.clone())
.unwrap()
.repository,
Some(repository)
);
store
.with_conn(|conn| {
conn.execute(
"DELETE FROM repositories WHERE workspace_id = ?1",
[&created.workspace.workspace_id],
)?;
Ok(())
})
.unwrap();
let replay = service.create(request, owner).unwrap();
assert!(replay.replayed);
assert!(replay.repository.is_none());
assert!(
store
.list_repositories(&replay.workspace.workspace_id)
.unwrap()
.is_empty()
);
}
#[test]
fn persisted_repository_free_receipt_replays_after_catalog_reopen() {
let temp = tempfile::tempdir().unwrap();
let database = temp.path().join("server.db");
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let request = WorkspaceCreateRequest {
operation_key: "persist-empty".into(),
display_name: "Workspace".into(),
repository: None,
};
let (owner, created) = {
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
let owner = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store, materials.clone());
let created = service.create(request.clone(), owner.clone()).unwrap();
(owner, created)
};
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
let reopened = WorkspaceCatalogService::new(store.clone(), materials);
let replay = reopened.create(request, owner).unwrap();
assert_eq!(replay.workspace, created.workspace);
assert!(replay.replayed);
assert!(replay.repository.is_none());
assert!(
store
.list_repositories(&replay.workspace.workspace_id)
.unwrap()
.is_empty()
);
}
#[test]
fn repository_present_fingerprint_preserves_saved_receipt_contract() {
let legacy_payload = serde_json::json!({
"requested_workspace_id": null, "display_name": "Workspace", "owner_account_id": "owner",
"repository": {"repository_key": "main", "uri": "/runtime/repo", "default_ref": "HEAD", "kind": "git"}
});
let legacy_digest = Sha256::digest(serde_json::to_vec(&legacy_payload).unwrap());
let legacy = format!(
"sha256:{}",
legacy_digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
assert_eq!(
workspace_create_fingerprint(
None,
"Workspace",
Some("owner"),
Some(("main", "/runtime/repo", "HEAD"))
),
legacy
);
assert_ne!(
workspace_create_fingerprint(None, "Workspace", Some("owner"), None),
legacy
);
}
#[test]
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
@@ -564,11 +766,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "organization-owner-create".to_string(),
display_name: "Organization Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
"organization-owner".to_string(),
)
@@ -605,11 +807,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "owner-a-create".to_string(),
display_name: "Owner A Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
owner_a.clone(),
)
@@ -619,11 +821,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "owner-b-create".to_string(),
display_name: "Owner B Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
}),
},
owner_b.clone(),
)
@@ -660,11 +862,11 @@ mod tests {
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
repository: Some(InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
repository_key: "remote".to_string(),
default_ref: Some("main".to_string()),
},
}),
},
owner_account_id,
)
@@ -673,7 +875,7 @@ mod tests {
let persisted = store
.get_repository(
&result.workspace.workspace_id,
&result.repository.repository_id,
&result.repository.as_ref().unwrap().repository_id,
)
.unwrap()
.unwrap();
@@ -45,6 +45,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"repository_ssh_host_trusts",
"server_secret_versions",
"ticket_assignment_operations",
"ticket_assignment_work_releases",
"ticket_assignment_ticket_tombstones",
"ticket_assignment_worker_tombstones",
"ticket_current_worker_assignments",
@@ -686,10 +687,10 @@ fn workspace_database_blockers(
for (sql, kind, resource_kind, message) in [
(
"SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id = ?1",
"SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1",
WorkspaceDeletionBlockerKind::WorkerRemovalBlocked,
"ticket",
"Remove current Ticket assignments before deleting the Workspace.",
"End unfinished Ticket work before deleting the Workspace.",
),
(
"SELECT COUNT(*)
+33
View File
@@ -24,6 +24,39 @@ Does not own:
Keeping product CLI ownership here prevents lower crates from depending on the binary name or parsing top-level arguments. Runtime launch should use typed command boundaries, not shell-command strings.
## Workspace creation and selection
`yoi init` remains Git-oriented and requires an explicit initial repository key:
```sh
yoi --backend https://backend.example init --display-name Development --repository-key platform
```
For a Workspace with no initial repository, use the same Backend creation API explicitly:
```sh
yoi --backend https://backend.example workspace create --display-name Research
```
Both commands record Workspace-to-Backend routing in the global client configuration,
not repository-local identity files. Repository-free creation prints the Workspace id;
select it explicitly from any directory, without Git discovery:
```sh
yoi --workspace-id <ID>
yoi --workspace-id <ID> workers
yoi --workspace-id <ID> resume
yoi --workspace-id <ID> panel
```
`--workspace-id` selects Backend even when `default_connection` is `local`, and conflicts
with `--local`. An explicit `--backend <URL>` overrides configured routing. Without an
explicit Workspace id, Backend commands retain Git-based Workspace discovery.
Normal Backend startup launches the existing workdir-free Worker form. Repository and
workdir attachment selection are not prerequisites imposed by this CLI; Runtime
availability and workdir requirements remain Backend/TUI responsibilities.
## See also
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
+6 -1
View File
@@ -250,10 +250,15 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
"--local and --backend are mutually exclusive".to_string(),
));
}
if explicit_local && workspace_id.is_some() {
return Err(ParseError(
"--local and --workspace-id are mutually exclusive".to_string(),
));
}
if explicit_local {
return resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget);
}
if explicit_backend_url.is_some() {
if explicit_backend_url.is_some() || workspace_id.is_some() {
return resolver.resolve_connection(
command,
CliConnectionInput::BackendTarget {
+322 -12
View File
@@ -27,7 +27,8 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
use serde::Deserialize;
use tui::{BackendWorkerPickerIntent, LaunchMode, LaunchOptions};
use workspace_bootstrap::{
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
InitOptions, WorkspaceCreateOptions, discover_repository_root, run_init, run_workspace_create,
select_backend_workspace_for_repository, workspace_creation_message,
};
#[derive(Debug)]
@@ -56,6 +57,8 @@ enum Mode {
no_wait: bool,
},
Init(InitOptions),
WorkspaceHelp,
WorkspaceCreate(WorkspaceCreateOptions),
WorkdirHelp,
WorkdirShare(workdir_share::WorkdirShareOptions),
WorkerRuntime(Vec<String>),
@@ -123,10 +126,7 @@ async fn run(mode: Mode) -> ExitCode {
},
Mode::Init(options) => match run_init(options).await {
Ok(workspace) => {
println!(
"Initialized Workspace '{}' for repository '{}'",
workspace.workspace.display_name, workspace.repository.repository_key
);
println!("{}", workspace_creation_message(&workspace));
ExitCode::SUCCESS
}
Err(error) => {
@@ -134,6 +134,20 @@ async fn run(mode: Mode) -> ExitCode {
ExitCode::FAILURE
}
},
Mode::WorkspaceHelp => {
print!("{WORKSPACE_HELP}");
ExitCode::SUCCESS
}
Mode::WorkspaceCreate(options) => match run_workspace_create(options).await {
Ok(workspace) => {
println!("{}", workspace_creation_message(&workspace));
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi workspace create: {error}");
ExitCode::FAILURE
}
},
Mode::WorkdirHelp => {
print_workdir_help();
ExitCode::SUCCESS
@@ -278,7 +292,7 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
command,
true,
None,
None,
selection.workspace_id.as_deref(),
);
}
@@ -521,6 +535,9 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Ticket { cli, target });
}
"workspace" => {
return parse_workspace_args(&args[1..], &target_selection);
}
"init" => {
if target_selection.explicit_local {
return Err(ParseError(
@@ -807,6 +824,68 @@ fn parse_workdir_args(
}))
}
const WORKSPACE_HELP: &str = "yoi workspace\n\nUsage:\n yoi [--backend <URL>] workspace create --display-name <NAME>\n\nCreate a Workspace without an initial repository; no Git checkout is needed.\nUse `yoi init` to create a Workspace with the current Git repository.\nSelect the created Workspace with `yoi --workspace-id <ID>`; this does not require Git.\n\nOptions:\n --display-name <NAME> Workspace display name (required)\n -h, --help Print help\n";
fn parse_workspace_args(args: &[String], selection: &TargetSelection) -> Result<Mode, ParseError> {
let Some((subcommand, args)) = args.split_first() else {
return Ok(Mode::WorkspaceHelp);
};
if matches!(subcommand.as_str(), "--help" | "-h") {
return Ok(Mode::WorkspaceHelp);
}
if subcommand != "create" {
return Err(ParseError(format!(
"unknown yoi workspace subcommand `{subcommand}`"
)));
}
if args
.iter()
.any(|arg| matches!(arg.as_str(), "--help" | "-h"))
{
return Ok(Mode::WorkspaceHelp);
}
if selection.explicit_local {
return Err(ParseError(
"yoi workspace create requires a Backend target and cannot use --local".to_string(),
));
}
if selection.workspace_id.is_some() {
return Err(ParseError(
"yoi workspace create creates a Workspace and does not accept --workspace-id"
.to_string(),
));
}
let mut display_name = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--display-name" => {
if display_name.is_some() {
return Err(ParseError(
"--display-name may only be provided once".to_string(),
));
}
display_name =
Some(required_option_value(args, index, "--display-name")?.to_string());
index += 2;
}
unknown => {
return Err(ParseError(format!(
"unknown yoi workspace create option `{unknown}`"
)));
}
}
}
let display_name = display_name.ok_or_else(|| {
ParseError("yoi workspace create requires --display-name NAME".to_string())
})?;
Ok(Mode::WorkspaceCreate(WorkspaceCreateOptions {
backend_url: resolve_backend_url(selection.backend_url.clone(), None)?,
display_name,
repository: None,
}))
}
fn parse_init_args(
args: &[String],
explicit_backend_url: Option<String>,
@@ -1989,6 +2068,7 @@ Usage:
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] workspace create --display-name <NAME>
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait]
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
@@ -1997,9 +2077,11 @@ Usage:
Target selection:
--local Use the client-owned one-process Standalone host
--backend <URL> Use a Workspace Backend explicitly
--workspace-id <ID> Scope Backend routes to a Workspace id
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
If no target is explicit, connection-aware commands use the merged client config.
Explicit --workspace-id selects Backend even when the default connection is local.
Without --workspace-id, Backend selection matches the current Git repository.
`default_connection = "local"` selects Standalone; it does not enable a filesystem
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
@@ -2017,6 +2099,7 @@ Console options:
--worker-id <ID> Backend Worker id; requires --runtime-id
Host commands:
yoi workspace create Create a Backend Workspace without an initial repository.
yoi init Register the current Git repository as a new Backend Workspace.
yoi workdir share Interactively share a client-hosted External Workdir.
keys Manage local model/API keys
@@ -2058,7 +2141,7 @@ Authority:
Options:
--backend <URL> Use this Workspace Backend
--workspace-id <ID> Scope Backend routes to a Workspace id
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
--workspace <PATH> Match this Git repository against Server DB Repository records
-r, --stopped List stopped Backend Workers
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
@@ -2077,7 +2160,7 @@ Usage:
Target options:
--local Restore from the client-owned Standalone Worker store
--backend <URL> Restore a stopped Backend Workspace Worker
--workspace-id <ID> Scope Backend routes to a Workspace id
--workspace-id <ID> Select a Backend Workspace explicitly; no Git checkout needed
Options:
--workspace <PATH> Scope Standalone Workers to this cwd identity (defaults to cwd)
@@ -2461,7 +2544,8 @@ backend = "shared"
)
);
assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins"));
assert!(!TOP_LEVEL_HELP.contains("yoi workspace"));
assert!(TOP_LEVEL_HELP.contains("yoi workspace create"));
assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve"));
assert!(!TOP_LEVEL_HELP.contains("yoi server"));
assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS"));
}
@@ -3174,6 +3258,232 @@ backend = "shared"
assert!(!error.contains("stale-local-workspace"));
}
#[test]
fn workspace_create_parsing_requires_no_repository_inputs() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
let args = [
"--backend",
"http://backend.example",
"workspace",
"create",
"--display-name",
"Research",
]
.map(String::from);
let Mode::WorkspaceCreate(options) =
parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected workspace creation mode");
};
assert_eq!(options.backend_url, "http://backend.example");
assert_eq!(options.display_name, "Research");
assert!(options.repository.is_none());
}
#[test]
fn workspace_create_rejects_repository_options_and_invalid_creation_selectors() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
for (prefix, suffix, expected) in [
(
vec!["--local"],
vec!["--display-name", "Research"],
"cannot use --local",
),
(
vec!["--workspace-id", "existing"],
vec!["--display-name", "Research"],
"does not accept --workspace-id",
),
(vec![], vec![], "requires --display-name NAME"),
(
vec![],
vec!["--display-name"],
"--display-name requires a value",
),
(
vec![],
vec!["--display-name", "A", "--display-name", "B"],
"may only be provided once",
),
(
vec![],
vec!["--display-name", "A", "--repository", "/tmp"],
"unknown yoi workspace create option `--repository`",
),
(
vec![],
vec!["--display-name", "A", "--repository-key", "main"],
"unknown yoi workspace create option `--repository-key`",
),
(
vec![],
vec!["--display-name", "A", "--default-ref", "main"],
"unknown yoi workspace create option `--default-ref`",
),
] {
let args = [prefix, vec!["workspace", "create"], suffix]
.concat()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err();
assert!(error.to_string().contains(expected), "{args:?}: {error}");
}
}
#[test]
fn workspace_help_documents_repo_free_creation_without_resolving_backend() {
for args in [
vec!["workspace"],
vec!["workspace", "--help"],
vec!["workspace", "create", "--help"],
] {
assert!(matches!(
parse_args_from(args).unwrap(),
Mode::WorkspaceHelp
));
}
assert!(TOP_LEVEL_HELP.contains("workspace create --display-name <NAME>"));
assert!(WORKSPACE_HELP.contains("without an initial repository"));
assert!(WORKSPACE_HELP.contains("--workspace-id <ID>"));
}
#[test]
fn explicit_workspace_selects_backend_from_non_git_directory_even_with_local_default() {
let directory = tempfile::tempdir().unwrap();
let resolver = FixedCliConnectionResolver {
backend_url: "http://configured-backend.example",
};
// This resolver defaults to Standalone and rejects repository-based selection.
for command in [None, Some("workers"), Some("resume"), Some("panel")] {
let mut args = vec!["--workspace-id".to_string(), "repo-free".to_string()];
if let Some(command) = command {
args.push(command.to_string());
}
args.extend([
"--workspace".to_string(),
directory.path().display().to_string(),
]);
let Mode::Tui {
target,
mode,
workspace_root,
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected TUI mode for {args:?}");
};
assert_eq!(workspace_root, directory.path());
assert_eq!(
target.resolve().unwrap(),
client::ResolvedTarget::Backend {
base_url: "http://configured-backend.example".to_string(),
workspace_id: "repo-free".to_string(),
}
);
match command {
None => assert!(matches!(mode, LaunchMode::BackendSpawn)),
Some("workers") => assert!(matches!(
mode,
LaunchMode::Workers {
intent: BackendWorkerPickerIntent::Attach { .. },
..
}
)),
Some("resume") => assert!(matches!(
mode,
LaunchMode::Workers {
intent: BackendWorkerPickerIntent::Resume,
..
}
)),
Some("panel") => assert!(matches!(mode, LaunchMode::Panel)),
_ => unreachable!(),
}
}
assert!(!directory.path().join(".git").exists());
assert!(!directory.path().join(".yoi").exists());
}
#[test]
fn explicit_workspace_attaches_to_backend_worker_without_git_discovery() {
let directory = tempfile::tempdir().unwrap();
let resolver = FixedCliConnectionResolver {
backend_url: "http://configured-backend.example",
};
let args = [
vec![
"--workspace-id=repo-free",
"--runtime-id",
"runtime-a",
"--worker-id",
"worker-a",
"--workspace",
]
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
vec![directory.path().display().to_string()],
]
.concat();
let Mode::Tui {
target,
mode:
LaunchMode::OpenWorker {
runtime_id,
worker_id,
},
..
} = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap()
else {
panic!("expected Backend Worker attach mode");
};
assert_eq!(
target.resolve().unwrap(),
client::ResolvedTarget::Backend {
base_url: "http://configured-backend.example".to_string(),
workspace_id: "repo-free".to_string(),
}
);
assert_eq!(runtime_id, "runtime-a");
assert_eq!(worker_id, "worker-a");
}
#[test]
fn explicit_workspace_conflicts_with_local_selection() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
let args = ["--local", "--workspace-id", "repo-free"].map(String::from);
let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err();
assert_eq!(
error.to_string(),
"--local and --workspace-id are mutually exclusive"
);
}
#[test]
fn init_rejects_non_git_repository_instead_of_creating_repo_free_workspace() {
let directory = tempfile::tempdir().unwrap();
let args = vec![
"--display-name".to_string(),
"Research".to_string(),
"--repository-key".to_string(),
"main".to_string(),
"--repository".to_string(),
directory.path().display().to_string(),
];
let error = parse_init_args(&args, Some("http://backend.example".to_string())).unwrap_err();
assert!(
error
.to_string()
.contains("not inside a readable Git repository")
);
}
#[test]
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
let repository = tempfile::tempdir().unwrap();
@@ -3398,9 +3708,9 @@ backend = "shared"
}
#[test]
fn parse_workspace_command_is_removed_from_yoi_surface() {
fn workspace_creation_does_not_restore_removed_workspace_server_command() {
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
assert_eq!(err.to_string(), "unknown command `workspace`");
assert_eq!(err.to_string(), "unknown yoi workspace subcommand `serve`");
}
#[test]
+129 -14
View File
@@ -19,6 +19,13 @@ pub(crate) struct InitOptions {
pub(crate) default_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceCreateOptions {
pub(crate) backend_url: String,
pub(crate) display_name: String,
pub(crate) repository: Option<CreateBackendWorkspaceRepository>,
}
pub(crate) async fn run_init(
options: InitOptions,
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
@@ -26,27 +33,56 @@ pub(crate) async fn run_init(
.repository_root
.to_str()
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
run_workspace_create(WorkspaceCreateOptions {
backend_url: options.backend_url,
display_name: options.display_name,
repository: Some(CreateBackendWorkspaceRepository {
repository_key: options.repository_key,
uri: repository_uri.to_string(),
default_ref: options.default_ref,
}),
})
.await
}
pub(crate) async fn run_workspace_create(
options: WorkspaceCreateOptions,
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
let target = BackendWorkspaceCatalogTarget {
base_url: options.backend_url.clone(),
};
let response = create_backend_workspace(
&target,
&CreateBackendWorkspaceRequest {
operation_key: uuid::Uuid::now_v7().to_string(),
display_name: options.display_name,
repository: CreateBackendWorkspaceRepository {
repository_key: options.repository_key,
uri: repository_uri.to_string(),
default_ref: options.default_ref,
},
},
)
.await
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
let request = workspace_create_request(options.display_name, options.repository);
let response = create_backend_workspace(&target, &request)
.await
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
Ok(response)
}
fn workspace_create_request(
display_name: String,
repository: Option<CreateBackendWorkspaceRepository>,
) -> CreateBackendWorkspaceRequest {
CreateBackendWorkspaceRequest {
operation_key: uuid::Uuid::now_v7().to_string(),
display_name,
repository,
}
}
pub(crate) fn workspace_creation_message(response: &server_api::WorkspaceCreateResponse) -> String {
match &response.repository {
Some(repository) => format!(
"Initialized Workspace '{}' for repository '{}'",
response.workspace.display_name, repository.repository_key
),
None => format!(
"Created Workspace '{}' (workspace-id: {}) without an initial repository",
response.workspace.display_name, response.workspace.workspace_id
),
}
}
pub(crate) fn select_backend_workspace_for_repository(
base_url: &str,
repository_path: &Path,
@@ -333,6 +369,85 @@ mod tests {
}
}
#[test]
fn workspace_creation_wire_request_omits_repository_for_repo_free_creation() {
let request = workspace_create_request("Research".to_string(), None);
let body = serde_json::to_value(request).unwrap();
assert_eq!(body["display_name"], "Research");
assert!(
body.get("repository")
.is_none_or(serde_json::Value::is_null)
);
}
#[test]
fn git_workspace_creation_wire_request_preserves_initial_repository() {
let request = workspace_create_request(
"Development".to_string(),
Some(CreateBackendWorkspaceRepository {
repository_key: "platform".to_string(),
uri: "/checkout/platform".to_string(),
default_ref: Some("develop".to_string()),
}),
);
let body = serde_json::to_value(request).unwrap();
assert_eq!(
body["repository"],
serde_json::json!({
"repository_key": "platform",
"uri": "/checkout/platform",
"default_ref": "develop",
})
);
}
#[test]
fn creation_response_with_null_repository_reports_workspace_including_on_replay() {
for replayed in [false, true] {
let body = serde_json::json!({
"workspace": workspace_summary("workspace-a", "Research"),
"repository": null,
"config_revision": 0,
"request_fingerprint": "fingerprint",
"replayed": replayed,
});
let response = serde_json::from_value(body).unwrap();
assert_eq!(
workspace_creation_message(&response),
"Created Workspace 'Research' (workspace-id: workspace-a) without an initial repository"
);
}
}
#[test]
fn creation_response_with_repository_preserves_git_init_message() {
let body = serde_json::json!({
"workspace": workspace_summary("workspace-a", "Development"),
"repository": {
"workspace_id": "workspace-a",
"repository_key": "platform",
"kind": "git",
"provider": "builtin:git",
"source": {"kind": "local_path", "uri": "/checkout/platform"},
"default_ref": "develop",
"source_revision": 1,
"source_fingerprint": "fingerprint",
"observed_status": "unverified",
"observed_at": null,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
},
"config_revision": 1,
"request_fingerprint": "fingerprint",
"replayed": false,
});
let response = serde_json::from_value(body).unwrap();
assert_eq!(
workspace_creation_message(&response),
"Initialized Workspace 'Development' for repository 'platform'"
);
}
#[test]
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
let repository = tempfile::tempdir().unwrap();