Merge branch 'develop' into hare/develop
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¤t),
|
||||
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(¤t),
|
||||
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"}"##;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+46
-86
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
+1550
-618
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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(*)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user