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
|
/// The callback is invoked with a short human-readable message
|
||||||
/// whenever the Engine encounters a condition that should be
|
/// 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
|
/// This channel is separate from `tracing::warn!`, which remains
|
||||||
/// in place for developer logs.
|
/// in place for developer logs.
|
||||||
pub fn on_warning(&mut self, callback: impl Fn(&str) + Send + Sync + 'static) {
|
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,
|
limit_bytes = limit,
|
||||||
"Tool output exceeded byte limit and was truncated"
|
"Tool output exceeded byte limit and was truncated"
|
||||||
);
|
);
|
||||||
self.emit_warning(&format!(
|
// Truncation is a tool-result constraint, not an actionable
|
||||||
"tool `{}` output truncated from {} to {} bytes (limit {})",
|
// engine warning. The content marker tells the model to refine
|
||||||
tool_call.name,
|
// its query; keep diagnostics in tracing, not user alerts.
|
||||||
before,
|
|
||||||
content.len(),
|
|
||||||
limit
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,182 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
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)]
|
#[derive(Clone)]
|
||||||
struct FailOnceClient {
|
struct FailOnceClient {
|
||||||
calls: Arc<AtomicUsize>,
|
calls: Arc<AtomicUsize>,
|
||||||
|
|||||||
@@ -434,11 +434,11 @@ mod tests {
|
|||||||
let request = CreateBackendWorkspaceRequest {
|
let request = CreateBackendWorkspaceRequest {
|
||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: Some(CreateBackendWorkspaceRepository {
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let retry = request.clone();
|
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(
|
pub fn set_ticket_workflow_state(
|
||||||
&self,
|
&self,
|
||||||
id: &TicketIdOrSlug,
|
id: &TicketIdOrSlug,
|
||||||
change: &TicketStateChange,
|
change: &TicketStateChange,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
|
let change = self.decision_from_state_change(id, change)?;
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let id = ticket_reference(id);
|
let id = ticket_reference(id);
|
||||||
let change = change.clone();
|
|
||||||
self.generated(move |client| async move {
|
self.generated(move |client| async move {
|
||||||
client
|
client
|
||||||
.ticket_workflow_state_set(
|
.ticket_workflow_state_set(
|
||||||
@@ -179,15 +205,23 @@ impl BackendWorkspaceProductClient {
|
|||||||
id: &TicketIdOrSlug,
|
id: &TicketIdOrSlug,
|
||||||
resolution: &MarkdownText,
|
resolution: &MarkdownText,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> 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 workspace_id = self.workspace_id.clone();
|
||||||
let id = ticket_reference(id);
|
let id = ticket_reference(id);
|
||||||
let resolution = resolution.clone();
|
|
||||||
self.generated(move |client| async move {
|
self.generated(move |client| async move {
|
||||||
client
|
client
|
||||||
.ticket_close_record(
|
.ticket_close_record(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
id,
|
id,
|
||||||
server_api::TicketCloseRecordRequest(resolution),
|
server_api::TicketCloseRecordRequest(request),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -633,6 +667,9 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
) -> ticket::Result<()> {
|
) -> ticket::Result<()> {
|
||||||
|
let change = self
|
||||||
|
.decision_from_state_change(&id, &change)
|
||||||
|
.map_err(ticket_client_error)?;
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let id = ticket_reference(&id);
|
let id = ticket_reference(&id);
|
||||||
self.generated(move |client| async move {
|
self.generated(move |client| async move {
|
||||||
@@ -672,6 +709,14 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
|||||||
field: &str,
|
field: &str,
|
||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
) -> ticket::Result<()> {
|
) -> 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 workspace_id = self.workspace_id.clone();
|
||||||
let id = ticket_reference(&id);
|
let id = ticket_reference(&id);
|
||||||
let field = field.to_string();
|
let field = field.to_string();
|
||||||
@@ -697,6 +742,26 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
|||||||
.map_err(ticket_client_error)
|
.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> {
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
||||||
let workspace_id = self.workspace_id.clone();
|
let workspace_id = self.workspace_id.clone();
|
||||||
let id = ticket_reference(&id);
|
let id = ticket_reference(&id);
|
||||||
@@ -903,8 +968,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn response_sequence_server(
|
fn response_sequence_server(
|
||||||
responses: Vec<(&'static str, &'static str)>,
|
responses: Vec<(&str, &str)>,
|
||||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
) -> (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 listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
let (sender, receiver) = mpsc::channel();
|
let (sender, receiver) = mpsc::channel();
|
||||||
@@ -927,6 +996,117 @@ mod tests {
|
|||||||
(format!("http://{address}"), receiver, handle)
|
(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]
|
#[test]
|
||||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
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"}"##;
|
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 {
|
let request = CreateBackendWorkspaceRequest {
|
||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: Some(CreateBackendWorkspaceRepository {
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
assert_eq!(request.clone(), request);
|
||||||
|
|||||||
+237
-288
@@ -236,10 +236,244 @@ pub struct MergeRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub thread: Vec<MergeRequestThreadEvent>,
|
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 {
|
impl MergeRequest {
|
||||||
pub fn effective_review(&self, subject: &str) -> Option<&ReviewEvent> {
|
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})
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -364,16 +598,8 @@ pub struct CompleteMergeRequest {
|
|||||||
pub auth: MergeRequestAuth,
|
pub auth: MergeRequestAuth,
|
||||||
pub now: DateTime<Utc>,
|
pub now: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
/// Historical MR-owned Ticket completion payload, retained for reading existing events.
|
||||||
pub struct CompleteTicket {
|
/// Canonical Ticket completion is owned by the Ticket domain.
|
||||||
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>,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct TicketCompletionEvent {
|
pub struct TicketCompletionEvent {
|
||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
@@ -979,7 +1205,7 @@ impl MergeRequestStore {
|
|||||||
}
|
}
|
||||||
let assignment_is_current: bool = transaction.query_row(
|
let assignment_is_current: bool = transaction.query_row(
|
||||||
"SELECT EXISTS(
|
"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
|
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
|
||||||
)",
|
)",
|
||||||
params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id],
|
params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id],
|
||||||
@@ -1098,227 +1324,6 @@ impl MergeRequestStore {
|
|||||||
Ok(event)
|
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(
|
pub fn repair_selector_from(
|
||||||
&self,
|
&self,
|
||||||
i: RepairSelectorFrom,
|
i: RepairSelectorFrom,
|
||||||
@@ -1764,62 +1769,6 @@ fn ticket_event(
|
|||||||
Ok(())
|
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> {
|
pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
||||||
match schema_state(c)? {
|
match schema_state(c)? {
|
||||||
SchemaState::Fresh => fresh(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 d = tempfile::tempdir().unwrap();
|
||||||
let p = d.path().join("db");
|
let p = d.path().join("db");
|
||||||
let c = Connection::open(&p).unwrap();
|
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);
|
drop(c);
|
||||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||||
assignment_id: "A".into(),
|
assignment_id: "A".into(),
|
||||||
@@ -166,7 +166,7 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects()
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let (d, s) = fixture();
|
||||||
open(&s);
|
open(&s);
|
||||||
let review = approve(&s, "opaque-source-ref", "token");
|
let review = approve(&s, "opaque-source-ref", "token");
|
||||||
@@ -226,43 +226,14 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(replayed, merged);
|
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 connection = Connection::open(d.path().join("db")).unwrap();
|
||||||
let (state, assignment_exists): (String, bool) = connection
|
let (state, active): (String, bool) = connection.query_row(
|
||||||
.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'",
|
||||||
"SELECT workflow_state, EXISTS(
|
[],
|
||||||
SELECT 1 FROM ticket_current_worker_assignments
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
WHERE workspace_id='W' AND ticket_id='T'
|
).unwrap();
|
||||||
) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
assert_eq!(state, "inprogress");
|
||||||
[],
|
assert!(active);
|
||||||
|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 json = serde_json::to_string(&mr).unwrap();
|
let json = serde_json::to_string(&mr).unwrap();
|
||||||
for banned in [
|
for banned in [
|
||||||
"revision_id",
|
"revision_id",
|
||||||
@@ -864,12 +835,12 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
|||||||
let (dir, store) = fixture();
|
let (dir, store) = fixture();
|
||||||
open(&store);
|
open(&store);
|
||||||
let integration_approval = approve(&store, "subject", "token-integration");
|
let integration_approval = approve(&store, "subject", "token-integration");
|
||||||
store
|
let merge = store
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
merge_request_id: "MR".into(),
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "merge".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(),
|
current_subject_ref: "subject".into(),
|
||||||
target_ref_before: "target-before".into(),
|
target_ref_before: "target-before".into(),
|
||||||
target_ref_after: "target-after".into(),
|
target_ref_after: "target-after".into(),
|
||||||
@@ -894,6 +865,26 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
drop(connection);
|
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
|
store
|
||||||
.register_reviewer_child_session(RegisterReviewerChildSession {
|
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||||
workspace_id: "W".into(),
|
workspace_id: "W".into(),
|
||||||
@@ -920,7 +911,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
|||||||
now: at(7),
|
now: at(7),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let requirement_approval = store
|
let requirement_review = store
|
||||||
.submit_review(SubmitMergeRequestReview {
|
.submit_review(SubmitMergeRequestReview {
|
||||||
merge_request_id: "MR".into(),
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
@@ -932,17 +923,33 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
|||||||
now: at(8),
|
now: at(8),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
let refreshed = store.list_for_ticket("W", "T").unwrap();
|
||||||
.complete_ticket(CompleteTicket {
|
assert_eq!(
|
||||||
ticket_id: "T".into(),
|
requirement_approval(
|
||||||
operation_id: "ticket-complete-post-rescope".into(),
|
&refreshed,
|
||||||
item_revision: "T:1".into(),
|
"T:1",
|
||||||
merge_request_ids: vec!["MR".into()],
|
&snapshot,
|
||||||
requirement_approval_event_id: requirement_approval.event_id,
|
Some(&requirement_review.event_id),
|
||||||
auth: auth(),
|
)
|
||||||
now: at(9),
|
.unwrap(),
|
||||||
})
|
&requirement_review
|
||||||
.unwrap();
|
);
|
||||||
|
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]
|
#[test]
|
||||||
@@ -978,19 +985,19 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let (dir, store) = fixture();
|
||||||
open_for(&store, "MR", "R");
|
open_for(&store, "MR", "R");
|
||||||
open_for(&store, "MR-2", "R2");
|
open_for(&store, "MR-2", "R2");
|
||||||
let first = approve_for(&store, "MR", "R", "subject-one", "token-one");
|
let first = approve_for(&store, "MR", "R", "subject-one", "token-one");
|
||||||
let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two");
|
let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two");
|
||||||
|
|
||||||
store
|
let first_merge = store
|
||||||
.complete(CompleteMergeRequest {
|
.complete(CompleteMergeRequest {
|
||||||
merge_request_id: "MR".into(),
|
merge_request_id: "MR".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
operation_id: "merge-one".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(),
|
current_subject_ref: "subject-one".into(),
|
||||||
target_ref_before: "target-one-before".into(),
|
target_ref_before: "target-one-before".into(),
|
||||||
target_ref_after: "target-one-after".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),
|
now: at(5),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
let partial = store.get_by_id("W", "MR-2").unwrap();
|
||||||
let (state, assigned): (String, bool) = connection
|
assert_eq!(
|
||||||
.query_row(
|
partial.integration_approval(),
|
||||||
"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'",
|
Err(MergeRequestEvidenceError::NotMerged)
|
||||||
[],
|
);
|
||||||
|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(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
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 {
|
.complete(CompleteMergeRequest {
|
||||||
merge_request_id: "MR-2".into(),
|
merge_request_id: "MR-2".into(),
|
||||||
ticket_id: "T".into(),
|
ticket_id: "T".into(),
|
||||||
@@ -1038,63 +1048,194 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple
|
|||||||
now: at(7),
|
now: at(7),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert_ticket_unchanged();
|
||||||
store.complete_ticket(CompleteTicket {
|
let requests = store.list_for_ticket("W", "T").unwrap();
|
||||||
ticket_id: "T".into(),
|
let snapshot = requests
|
||||||
operation_id: "ticket-complete".into(),
|
.iter()
|
||||||
item_revision: "stale".into(),
|
.map(|request| MergeRequestReviewSubject {
|
||||||
merge_request_ids: vec!["MR".into(), "MR-2".into()],
|
merge_request_id: request.merge_request_id.clone(),
|
||||||
requirement_approval_event_id: second.event_id.clone(),
|
subject_ref: request.merged_result().unwrap().approved_source_ref.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),
|
|
||||||
})
|
})
|
||||||
|
.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();
|
.unwrap();
|
||||||
let replay = store
|
let second_request = requests
|
||||||
.complete_ticket(CompleteTicket {
|
.iter()
|
||||||
ticket_id: "T".into(),
|
.find(|request| request.merge_request_id == "MR-2")
|
||||||
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),
|
|
||||||
})
|
|
||||||
.unwrap();
|
.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,
|
SubmitMergeRequestReviewRequest,
|
||||||
TextResponse,
|
TextResponse,
|
||||||
TicketCloseRecordRequest,
|
TicketCloseRecordRequest,
|
||||||
TicketCompletionEvent,
|
|
||||||
TicketDependencyCheckResponse,
|
TicketDependencyCheckResponse,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
TicketDoctorResponse,
|
TicketDoctorResponse,
|
||||||
@@ -350,6 +349,7 @@ impl_openapi_schema!(
|
|||||||
TicketRoleAssignmentsResponse,
|
TicketRoleAssignmentsResponse,
|
||||||
TicketShowRequest,
|
TicketShowRequest,
|
||||||
TicketStateChangeRequest,
|
TicketStateChangeRequest,
|
||||||
|
TicketStateUpdateRequest,
|
||||||
TicketSummarySearchQuery,
|
TicketSummarySearchQuery,
|
||||||
TicketThreadEventRequest,
|
TicketThreadEventRequest,
|
||||||
RuntimeManagementApiError,
|
RuntimeManagementApiError,
|
||||||
@@ -2917,7 +2917,7 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: TicketThreadEventRequest,
|
#[body] request: TicketThreadEventRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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(
|
async fn ticket_state_change_add(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2933,7 +2933,7 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: TicketIntakeSummaryRequest,
|
#[body] request: TicketIntakeSummaryRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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(
|
async fn ticket_state_field_set(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2942,7 +2942,7 @@ pub trait ServerApi {
|
|||||||
#[path] field: String,
|
#[path] field: String,
|
||||||
#[body] request: TicketStateChangeRequest,
|
#[body] request: TicketStateChangeRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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(
|
async fn ticket_workflow_state_set(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -2950,6 +2950,14 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: TicketStateChangeRequest,
|
#[body] request: TicketStateChangeRequest,
|
||||||
) -> Result<(), RepositoryApiError>;
|
) -> 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)]
|
#[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(
|
async fn ticket_mark_ready_record(
|
||||||
&self,
|
&self,
|
||||||
@@ -3047,15 +3055,15 @@ pub trait ServerApi {
|
|||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: CompleteMergeRequestRequest,
|
#[body] request: CompleteMergeRequestRequest,
|
||||||
) -> Result<MergeEvent, RepositoryApiError>;
|
) -> 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(
|
async fn ticket_complete(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: CompleteTicketRequest,
|
#[body] request: CompleteTicketRequest,
|
||||||
) -> Result<TicketCompletionEvent, RepositoryApiError>;
|
) -> Result<TicketRecord, 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)]
|
#[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(
|
async fn ticket_close_record(
|
||||||
&self,
|
&self,
|
||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
@@ -3150,9 +3158,10 @@ pub trait ServerApi {
|
|||||||
#[body] request: CancelTicketImplementationRequest,
|
#[body] request: CancelTicketImplementationRequest,
|
||||||
) -> Result<TicketDetail, RepositoryApiError>;
|
) -> 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(
|
async fn ticket_state_transition(
|
||||||
&self,
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: BrowserTransitionTicketStateRequest,
|
#[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)]
|
#[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(
|
async fn ticket_ready(
|
||||||
&self,
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: TicketMarkReadyRequest,
|
#[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)]
|
#[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(
|
async fn ticket_queue(
|
||||||
&self,
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: BrowserQueueTicketRequest,
|
#[body] request: BrowserQueueTicketRequest,
|
||||||
) -> Result<TicketQueueResponse, RepositoryApiError>;
|
) -> 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(
|
async fn ticket_close(
|
||||||
&self,
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] id: String,
|
#[path] id: String,
|
||||||
#[body] request: BrowserCloseTicketRequest,
|
#[body] request: BrowserCloseTicketRequest,
|
||||||
@@ -4239,22 +4251,27 @@ pub struct InitialRepositoryIntent {
|
|||||||
pub default_ref: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceCreateRequest {
|
pub struct WorkspaceCreateRequest {
|
||||||
pub operation_key: String,
|
pub operation_key: String,
|
||||||
pub display_name: 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceCreateResponse {
|
pub struct WorkspaceCreateResponse {
|
||||||
pub workspace: WorkspaceSummary,
|
pub workspace: WorkspaceSummary,
|
||||||
pub repository: WorkspaceRepositoryRecord,
|
pub repository: Option<WorkspaceRepositoryRecord>,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
@@ -7404,6 +7421,15 @@ pub struct TicketActionEligibility {
|
|||||||
pub blockers: Vec<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -7416,6 +7442,8 @@ pub struct TicketMergeRequestSummary {
|
|||||||
pub selector_to: String,
|
pub selector_to: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub current_subject_ref: Option<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_subject_ref: Option<String>,
|
||||||
pub review_requested_at: Option<String>,
|
pub review_requested_at: Option<String>,
|
||||||
pub review_submitted_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!(CreateTicketRecordRequest, ticket::NewTicket);
|
||||||
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
|
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
|
||||||
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
|
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!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
|
||||||
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText);
|
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
|
||||||
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
|
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
|
||||||
transparent_ticket_dto!(
|
transparent_ticket_dto!(
|
||||||
CreateTicketOrchestrationPlanRequest,
|
CreateTicketOrchestrationPlanRequest,
|
||||||
@@ -7763,9 +7792,11 @@ pub struct BrowserEditTicketRequest {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrowserTransitionTicketStateRequest {
|
pub struct BrowserTransitionTicketStateRequest {
|
||||||
pub state: BrowserTicketWorkflowState,
|
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 body: Option<String>,
|
||||||
pub author: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -7796,6 +7827,9 @@ pub struct BrowserQueueTicketRequest {}
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BrowserCloseTicketRequest {
|
pub struct BrowserCloseTicketRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub expected_item_revision: String,
|
||||||
|
pub expected_state: BrowserTicketWorkflowState,
|
||||||
pub resolution: String,
|
pub resolution: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8174,22 +8208,12 @@ pub struct CompleteMergeRequestRequest {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CompleteTicketRequest {
|
pub struct CompleteTicketRequest {
|
||||||
pub operation_id: String,
|
pub operation_key: String,
|
||||||
pub item_revision: String,
|
pub expected_item_revision: String,
|
||||||
pub merge_request_ids: Vec<String>,
|
pub expected_state: ticket::TicketWorkflowState,
|
||||||
pub requirement_approval_event_id: String,
|
pub reason: String,
|
||||||
}
|
#[serde(default)]
|
||||||
|
pub references: Vec<ticket::TicketReference>,
|
||||||
#[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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -11450,13 +11474,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() {
|
fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() {
|
||||||
let operations = ServerApiMetadata::OPERATIONS;
|
let operations = ServerApiMetadata::OPERATIONS;
|
||||||
assert_eq!(
|
|
||||||
operations
|
|
||||||
.iter()
|
|
||||||
.filter(|operation| operation.operation_id.starts_with("ticket_"))
|
|
||||||
.count(),
|
|
||||||
38
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
operations
|
operations
|
||||||
.iter()
|
.iter()
|
||||||
@@ -11472,6 +11489,16 @@ mod tests {
|
|||||||
11
|
11
|
||||||
);
|
);
|
||||||
for (operation_id, method, path) in [
|
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",
|
"ticket_relation_query",
|
||||||
HttpMethod::Post,
|
HttpMethod::Post,
|
||||||
@@ -12193,12 +12220,27 @@ mod tests {
|
|||||||
let request = WorkspaceCreateRequest {
|
let request = WorkspaceCreateRequest {
|
||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Workspace".to_string(),
|
display_name: "Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
uri: "/srv/repositories/main".to_string(),
|
uri: "/srv/repositories/main".to_string(),
|
||||||
default_ref: Some("develop".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();
|
let json = serde_json::to_value(&request).unwrap();
|
||||||
assert_eq!(json["operation_key"], "workspace-create-1");
|
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||||
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
|
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
|
||||||
@@ -12529,6 +12571,34 @@ mod tests {
|
|||||||
assert!(serde_json::from_value::<RuntimeConnectionTestResponse>(unknown).is_err());
|
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")]
|
#[cfg(feature = "typescript")]
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
|
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
|
||||||
@@ -13377,7 +13447,6 @@ mod openapi_artifact_tests {
|
|||||||
"merge_request_review_revoke",
|
"merge_request_review_revoke",
|
||||||
"merge_request_review_submit",
|
"merge_request_review_submit",
|
||||||
"merge_request_reviewer_child_register",
|
"merge_request_reviewer_child_register",
|
||||||
"ticket_complete",
|
|
||||||
"runtime_resource_fetch",
|
"runtime_resource_fetch",
|
||||||
"worker_control_cancel",
|
"worker_control_cancel",
|
||||||
"worker_control_input",
|
"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 =
|
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
"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. \
|
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 \
|
Targets are optional; provided targets are validated against the Workspace catalog without Git resolution. \
|
||||||
read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional.";
|
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. \
|
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.";
|
state_changed event, effective targets, and planning -> ready transition atomically.";
|
||||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
const QUEUE_DESCRIPTION: &str = "Queue only the explicitly requested Ticket for Orchestrator routing through the typed \
|
||||||
Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \
|
Ticket backend, regardless of state or dependencies. Dependency diagnostics are retained; no dependencies are auto-queued.";
|
||||||
requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged.";
|
const WORKFLOW_STATE_DESCRIPTION: &str = "Change Ticket state with a bounded audited state_changed event. \
|
||||||
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
|
State and dependency metadata do not impose an implementation phase path. Reopening does not revive released work identities.";
|
||||||
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 CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \
|
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.";
|
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 \
|
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)
|
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> {
|
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
|
||||||
self.backend.mark_ready(id, request)
|
self.backend.mark_ready(id, request)
|
||||||
}
|
}
|
||||||
@@ -1953,7 +1962,7 @@ mod tests {
|
|||||||
) -> crate::Result<crate::ResolvedTicketTarget> {
|
) -> crate::Result<crate::ResolvedTicketTarget> {
|
||||||
Ok(crate::ResolvedTicketTarget {
|
Ok(crate::ResolvedTicketTarget {
|
||||||
repository_key: repository_key.to_owned(),
|
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,
|
access: crate::TicketTargetAccess::ReadOnly,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2061,15 +2070,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_tool_description_explains_queued_acceptance() {
|
fn state_tool_description_explains_phase_independence() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
let definition = ticket_tools(backend(&temp))
|
let definition = ticket_tools(backend(&temp))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|definition| definition().0.name == "TicketWorkflowState")
|
.find(|definition| definition().0.name == "TicketWorkflowState")
|
||||||
.expect("state tool exists");
|
.expect("state tool exists");
|
||||||
let (meta, _) = definition();
|
let (meta, _) = definition();
|
||||||
assert!(meta.description.contains("queued -> inprogress"));
|
assert!(
|
||||||
assert!(meta.description.contains("implementation side effects"));
|
meta.description
|
||||||
|
.contains("do not impose an implementation phase path")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meta.description
|
||||||
|
.contains("does not revive released work identities")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2668,10 +2683,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(queued.summary.contains("2 ticket(s)"));
|
assert!(queued.summary.contains("1 ticket(s)"));
|
||||||
let queued_content = queued.content.unwrap();
|
let queued_content = queued.content.unwrap();
|
||||||
assert!(queued_content.contains(&target_key));
|
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(&target.id));
|
||||||
assert!(!queued_content.contains(&dependency.id));
|
assert!(!queued_content.contains(&dependency.id));
|
||||||
|
|
||||||
@@ -2931,79 +2946,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||||
|
for (from, to) in [
|
||||||
let mut ready_input = NewTicket::new("Ready Bypass");
|
(TicketWorkflowState::Ready, TicketWorkflowState::InProgress),
|
||||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
(TicketWorkflowState::Done, TicketWorkflowState::Planning),
|
||||||
let ready = backend.create(ready_input).unwrap();
|
(TicketWorkflowState::Queued, TicketWorkflowState::Done),
|
||||||
let ready_error = workflow
|
] {
|
||||||
.execute(
|
let mut input = NewTicket::new(format!("{from} to {to}"));
|
||||||
&json!({
|
input.workflow_state = Some(from);
|
||||||
"ticket": ready.id,
|
let ticket = backend.create(input).unwrap();
|
||||||
"from": "ready",
|
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();
|
||||||
"to": "inprogress",
|
assert_eq!(
|
||||||
"reason": "bypass_queue",
|
backend.show(ticket.id.into()).unwrap().meta.workflow_state,
|
||||||
"body": "Should not bypass Queue.\n"
|
to
|
||||||
})
|
);
|
||||||
.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")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -199,30 +199,40 @@ fn prompt_create_request_with_io(
|
|||||||
if display_name.is_empty() {
|
if display_name.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else {
|
let Some(uri) = prompt_line(
|
||||||
return Ok(None);
|
input,
|
||||||
};
|
output,
|
||||||
let uri = uri.trim().to_string();
|
"Initial repository absolute path/URI (optional, Enter to skip): ",
|
||||||
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]: ")?
|
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
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!(
|
let operation_key = format!(
|
||||||
"tui-workspace-create-{}-{}",
|
"tui-workspace-create-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
@@ -234,11 +244,7 @@ fn prompt_create_request_with_io(
|
|||||||
Ok(Some(CreateBackendWorkspaceRequest {
|
Ok(Some(CreateBackendWorkspaceRequest {
|
||||||
operation_key,
|
operation_key,
|
||||||
display_name,
|
display_name,
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository,
|
||||||
uri,
|
|
||||||
repository_key,
|
|
||||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]
|
#[test]
|
||||||
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
|
fn workspace_creation_preserves_an_explicit_non_main_repository_key() {
|
||||||
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
|
let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n");
|
||||||
let request = request.expect("complete input should create a request");
|
let request = request.expect("complete input should create a request");
|
||||||
|
|
||||||
assert_eq!(request.display_name, "Platform");
|
assert_eq!(request.display_name, "Platform");
|
||||||
assert_eq!(request.repository.uri, "/srv/platform");
|
assert_eq!(request.repository.as_ref().unwrap().uri, "/srv/platform");
|
||||||
assert_eq!(request.repository.repository_key, "platform");
|
assert_eq!(
|
||||||
assert_eq!(request.repository.default_ref.as_deref(), Some("develop"));
|
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 (required): "));
|
||||||
assert!(!output.contains("Repository key [main]"));
|
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, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n");
|
||||||
let request = request.expect("a later explicit key should create a request");
|
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 is required.").count(), 2);
|
||||||
assert_eq!(output.matches("Repository key (required): ").count(), 3);
|
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");
|
let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.unwrap().repository.repository_key,
|
request.unwrap().repository.unwrap().repository_key,
|
||||||
" platform ",
|
" platform ",
|
||||||
"the Backend must validate the creator's exact Repository key"
|
"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");
|
let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.unwrap().repository.repository_key,
|
request.unwrap().repository.unwrap().repository_key,
|
||||||
"main",
|
"main",
|
||||||
"main remains valid when the creator enters it explicitly"
|
"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();
|
wire_engine_warnings(worker, alerter);
|
||||||
worker.on_warning(move |message| {
|
|
||||||
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned());
|
|
||||||
});
|
|
||||||
|
|
||||||
// History-append broadcasts (previously `Event::SystemMessage`)
|
// History-append broadcasts (previously `Event::SystemMessage`)
|
||||||
// have been removed: every persistent history item is now committed
|
// 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.
|
// 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(
|
fn validate_memory_lifecycle_targets(
|
||||||
memory: &manifest::MemoryFeatureProfileConfig,
|
memory: &manifest::MemoryFeatureProfileConfig,
|
||||||
subjektiv: &manifest::SubjektivFeatureProfileConfig,
|
subjektiv: &manifest::SubjektivFeatureProfileConfig,
|
||||||
@@ -3906,6 +3914,198 @@ mod tests {
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::net::UnixListener;
|
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]
|
#[test]
|
||||||
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
|
fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() {
|
||||||
let production = include_str!("controller.rs")
|
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."
|
"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 => {
|
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")
|
.expect("static Merge Request workflow instruction declaration is valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_KINDS: [Kind; 6] = [
|
const ALL_KINDS: [Kind; 5] = [
|
||||||
Kind::Show,
|
Kind::Show,
|
||||||
Kind::Open,
|
Kind::Open,
|
||||||
Kind::Review,
|
Kind::Review,
|
||||||
Kind::Readiness,
|
Kind::Readiness,
|
||||||
Kind::Complete,
|
Kind::Complete,
|
||||||
Kind::CompleteTicket,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -56,7 +55,6 @@ enum Kind {
|
|||||||
Readiness,
|
Readiness,
|
||||||
Open,
|
Open,
|
||||||
Complete,
|
Complete,
|
||||||
CompleteTicket,
|
|
||||||
Review,
|
Review,
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -98,14 +96,6 @@ struct CompleteMergeRequestInput {
|
|||||||
resolution: MergeResolutionInput,
|
resolution: MergeResolutionInput,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum MergeStrategyInput {
|
enum MergeStrategyInput {
|
||||||
FastForward,
|
FastForward,
|
||||||
@@ -150,7 +140,7 @@ impl Kind {
|
|||||||
Self::Open => config.open,
|
Self::Open => config.open,
|
||||||
Self::Review => config.review,
|
Self::Review => config.review,
|
||||||
Self::Readiness => config.readiness_check,
|
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::Readiness => "CheckMergeRequestReadiness",
|
||||||
Self::Open => "OpenMergeRequest",
|
Self::Open => "OpenMergeRequest",
|
||||||
Self::Complete => "CompleteMergeRequest",
|
Self::Complete => "CompleteMergeRequest",
|
||||||
Self::CompleteTicket => "CompleteTicket",
|
|
||||||
Self::Review => "ReviewMergeRequest",
|
Self::Review => "ReviewMergeRequest",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,16 +159,12 @@ impl Kind {
|
|||||||
Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
|
Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
|
||||||
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
|
||||||
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
|
||||||
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
|
|
||||||
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mutating(self) -> bool {
|
fn mutating(self) -> bool {
|
||||||
matches!(
|
matches!(self, Self::Open | Self::Review | Self::Complete)
|
||||||
self,
|
|
||||||
Self::Open | Self::Review | Self::Complete | Self::CompleteTicket
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[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 => {
|
Kind::Review => {
|
||||||
let v: ReviewMergeRequestInput = parse(input)?;
|
let v: ReviewMergeRequestInput = parse(input)?;
|
||||||
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
let ctx = self.client.reviewer_context().ok_or_else(|| {
|
||||||
@@ -499,11 +467,6 @@ fn native_merge_request_operation(kind: Kind) -> NativeMergeRequestOperation {
|
|||||||
surface: NativeMergeRequestSurface::Item,
|
surface: NativeMergeRequestSurface::Item,
|
||||||
identity_field: Some("merge_request_id"),
|
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(
|
let collection_descriptor = merge_request_descriptor(
|
||||||
&collection_tools,
|
&collection_tools,
|
||||||
"Open Merge Requests and complete Tickets through existing scoped authority",
|
"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 retains its full guarded result-set inputs.",
|
"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 {
|
registry.mount(WipProjection {
|
||||||
route: collection_route.clone(),
|
route: collection_route.clone(),
|
||||||
@@ -617,7 +580,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
|||||||
let item_descriptor = merge_request_descriptor(
|
let item_descriptor = merge_request_descriptor(
|
||||||
&item_tools,
|
&item_tools,
|
||||||
"Inspect and operate on the Merge Request bound to this object route",
|
"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 {
|
registry.mount_dynamic(WipDynamicMount {
|
||||||
collection_route: collection_route.clone(),
|
collection_route: collection_route.clone(),
|
||||||
@@ -665,18 +628,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
|
|||||||
.get(operation)
|
.get(operation)
|
||||||
.ok_or_else(merge_request_operation_not_found)?;
|
.ok_or_else(merge_request_operation_not_found)?;
|
||||||
let input = native_merge_request_input(arguments, None, tool.projection.identity_field)?;
|
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 =
|
let output =
|
||||||
execute_native_merge_request_tool(tool, &self.permissions, input, None, context)
|
execute_native_merge_request_tool(tool, &self.permissions, input, None, context)
|
||||||
.await?;
|
.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(
|
Ok(WipOperationOutput::native(
|
||||||
@@ -1148,9 +1087,6 @@ pub fn description(n: &str) -> Option<&'static str> {
|
|||||||
"CompleteMergeRequest" => Some(
|
"CompleteMergeRequest" => Some(
|
||||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
"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(
|
"ReviewMergeRequest" => Some(
|
||||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
"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",
|
"CheckMergeRequestReadiness",
|
||||||
"OpenMergeRequest",
|
"OpenMergeRequest",
|
||||||
"CompleteMergeRequest",
|
"CompleteMergeRequest",
|
||||||
"CompleteTicket",
|
|
||||||
"ReviewMergeRequest",
|
"ReviewMergeRequest",
|
||||||
] {
|
] {
|
||||||
assert!(description(name).is_some(), "missing operation {name}");
|
assert!(description(name).is_some(), "missing operation {name}");
|
||||||
@@ -1369,8 +1304,7 @@ mod tests {
|
|||||||
[
|
[
|
||||||
"ShowMergeRequest",
|
"ShowMergeRequest",
|
||||||
"CheckMergeRequestReadiness",
|
"CheckMergeRequestReadiness",
|
||||||
"CompleteMergeRequest",
|
"CompleteMergeRequest"
|
||||||
"CompleteTicket"
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||||
@@ -1427,7 +1361,6 @@ mod tests {
|
|||||||
(NativeMergeRequestSurface::Item, "read"),
|
(NativeMergeRequestSurface::Item, "read"),
|
||||||
(NativeMergeRequestSurface::Item, "check_readiness"),
|
(NativeMergeRequestSurface::Item, "check_readiness"),
|
||||||
(NativeMergeRequestSurface::Item, "complete"),
|
(NativeMergeRequestSurface::Item, "complete"),
|
||||||
(NativeMergeRequestSurface::Collection, "complete_ticket"),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
use server_api::TicketSourceRefObservation;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub(super) struct ModelTicketQueryResponse {
|
pub(super) struct ModelTicketQueryResponse {
|
||||||
@@ -168,17 +169,22 @@ struct ModelMergeRequest {
|
|||||||
selector_to: String,
|
selector_to: String,
|
||||||
review_status: String,
|
review_status: String,
|
||||||
subject_ref: Option<String>,
|
subject_ref: Option<String>,
|
||||||
|
source_ref_observation: TicketSourceRefObservation,
|
||||||
|
integration_evidence_error: Option<String>,
|
||||||
review_excerpt: Option<String>,
|
review_excerpt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ModelTicketEvidence {
|
struct ModelTicketEvidence {
|
||||||
|
/// MR evidence is inapplicable without a linked MR, independent of Ticket decisions.
|
||||||
|
integration_applicable: bool,
|
||||||
has_merge_request: bool,
|
has_merge_request: bool,
|
||||||
has_current_subject_ref: bool,
|
has_current_subject_ref: bool,
|
||||||
has_review_request: bool,
|
has_review_request: bool,
|
||||||
has_commit: bool,
|
has_commit: bool,
|
||||||
review_status: Option<String>,
|
review_status: Option<String>,
|
||||||
approved_current_subject: bool,
|
approved_current_subject: bool,
|
||||||
|
review_after_rescope: bool,
|
||||||
unresolved_request_changes: bool,
|
unresolved_request_changes: bool,
|
||||||
complete_for_integration: bool,
|
complete_for_integration: bool,
|
||||||
missing: Vec<String>,
|
missing: Vec<String>,
|
||||||
@@ -517,23 +523,47 @@ fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
|
|||||||
selector_to: string_field(merge, "selector_to")?,
|
selector_to: string_field(merge, "selector_to")?,
|
||||||
review_status: string_field(merge, "review_status")?,
|
review_status: string_field(merge, "review_status")?,
|
||||||
subject_ref: optional_string(merge, "current_subject_ref")?,
|
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")?,
|
review_excerpt: optional_string(merge, "review_excerpt")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
|
||||||
let evidence = object(value, "Ticket evidence")?;
|
let evidence = object(value, "Ticket evidence")?;
|
||||||
Ok(ModelTicketEvidence {
|
let has_merge_request = bool_field(evidence, "has_merge_request")?;
|
||||||
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_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
|
||||||
has_review_request: bool_field(evidence, "has_review_request")?,
|
has_review_request: bool_field(evidence, "has_review_request")?,
|
||||||
has_commit: bool_field(evidence, "has_commit")?,
|
has_commit: bool_field(evidence, "has_commit")?,
|
||||||
review_status: optional_string(evidence, "review_status")?,
|
review_status: optional_string(evidence, "review_status")?,
|
||||||
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
|
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")?,
|
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
|
||||||
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
|
||||||
missing: string_array(evidence, "missing")?,
|
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> {
|
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||||
@@ -664,6 +694,38 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn objective_projection_exposes_only_resource_references() {
|
fn objective_projection_exposes_only_resource_references() {
|
||||||
let projected = project_objective_detail(json!({
|
let projected = project_objective_detail(json!({
|
||||||
@@ -735,6 +797,7 @@ mod tests {
|
|||||||
"has_commit": false,
|
"has_commit": false,
|
||||||
"review_status": null,
|
"review_status": null,
|
||||||
"approved_current_subject": false,
|
"approved_current_subject": false,
|
||||||
|
"review_after_rescope": false,
|
||||||
"unresolved_request_changes": false,
|
"unresolved_request_changes": false,
|
||||||
"complete_for_integration": false,
|
"complete_for_integration": false,
|
||||||
"missing": ["merge_request"]
|
"missing": ["merge_request"]
|
||||||
@@ -817,6 +880,7 @@ mod tests {
|
|||||||
"has_commit": false,
|
"has_commit": false,
|
||||||
"review_status": null,
|
"review_status": null,
|
||||||
"approved_current_subject": false,
|
"approved_current_subject": false,
|
||||||
|
"review_after_rescope": false,
|
||||||
"unresolved_request_changes": false,
|
"unresolved_request_changes": false,
|
||||||
"complete_for_integration": false,
|
"complete_for_integration": false,
|
||||||
"missing": ["merge_request"]
|
"missing": ["merge_request"]
|
||||||
@@ -844,6 +908,123 @@ mod tests {
|
|||||||
assert!(!projected.to_string().contains("internal-blocker"));
|
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]
|
#[test]
|
||||||
fn relation_projection_accepts_current_workspace_api_shapes() {
|
fn relation_projection_accepts_current_workspace_api_shapes() {
|
||||||
let outgoing = project_relation(
|
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
|
//! Separate from `tracing` (which is for developer logs). Alerts
|
||||||
//! are short human-readable messages the Worker layer wants a client to
|
//! 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
|
//! Each alert is broadcast on the shared `Event` channel and
|
||||||
//! also appended to an in-memory buffer so that clients connecting
|
//! also appended to an in-memory buffer so that clients connecting
|
||||||
|
|||||||
@@ -1027,7 +1027,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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()
|
let rendered = PromptCatalog::builtins_only()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.render_name("role.orchestrator", Value::UNDEFINED)
|
.render_name("role.orchestrator", Value::UNDEFINED)
|
||||||
@@ -1040,7 +1040,21 @@ mod tests {
|
|||||||
assert!(rendered.contains("call `WorkerStop`"));
|
assert!(rendered.contains("call `WorkerStop`"));
|
||||||
assert!(rendered.contains("call `WorkerRemove`"));
|
assert!(rendered.contains("call `WorkerRemove`"));
|
||||||
assert!(rendered.contains("only then call `WorkdirDelete`"));
|
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"));
|
assert!(!rendered.contains("{% include"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6119,17 +6119,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.to_string(),
|
.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 {
|
let orchestrator_config = MergeRequestFeatureConfig {
|
||||||
readiness_check: true,
|
readiness_check: true,
|
||||||
@@ -6202,51 +6191,22 @@ mod tests {
|
|||||||
.inspect("yoi.merge-request/collection/v1".into(), false)
|
.inspect("yoi.merge-request/collection/v1".into(), false)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let before_ticket_completion = orchestrator
|
let rejected = orchestrator
|
||||||
.host
|
|
||||||
.projection(&item)
|
|
||||||
.unwrap()
|
|
||||||
.object
|
|
||||||
.validator;
|
|
||||||
orchestrator
|
|
||||||
.call(
|
.call(
|
||||||
collection.into(),
|
collection.into(),
|
||||||
"yoi.merge-request/collection/v1".into(),
|
"yoi.merge-request/collection/v1".into(),
|
||||||
"complete_ticket".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!({}),
|
json!({}),
|
||||||
ToolExecutionContext::direct(),
|
ToolExecutionContext::direct(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("affected Merge Request observation must be stale");
|
.expect_err("Ticket completion is not a Merge Request operation");
|
||||||
assert!(stale.to_string().contains("ValidatorMismatch"));
|
assert!(
|
||||||
|
rejected.to_string().contains("operation is not declared"),
|
||||||
|
"{rejected}"
|
||||||
|
);
|
||||||
let orchestrator_requests = orchestrator_client.requests();
|
let orchestrator_requests = orchestrator_client.requests();
|
||||||
assert_eq!(orchestrator_requests.len(), 3);
|
assert_eq!(orchestrator_requests.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
orchestrator_requests[0].path,
|
orchestrator_requests[0].path,
|
||||||
"/api/w/workspace/merge-requests/MR-1/readiness"
|
"/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["approval_event_id"], "review-1");
|
||||||
assert_eq!(completion_body["target_ref_before"], "target-1");
|
assert_eq!(completion_body["target_ref_before"], "target-1");
|
||||||
assert_eq!(completion_body["target_ref_after"], "result-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]
|
#[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)
|
FOREIGN KEY (workspace_id, ticket_id)
|
||||||
REFERENCES typed_tickets(workspace_id, ticket_id)
|
REFERENCES typed_tickets(workspace_id, ticket_id)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
|
||||||
REFERENCES worker_registry(workspace_id, runtime_id, worker_id)
|
|
||||||
ON DELETE RESTRICT,
|
|
||||||
CHECK(
|
CHECK(
|
||||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||||
OR
|
OR
|
||||||
@@ -1219,9 +1216,6 @@ CREATE INDEX ticket_current_principal_idx
|
|||||||
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
||||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||||
WHERE role IN ('orchestrator', 'coder');
|
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
|
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||||
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||||
|
|||||||
@@ -75,18 +75,6 @@ impl RepositoryRegistryReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(&self) -> RepositoryListProjection {
|
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 {
|
RepositoryListProjection {
|
||||||
items: self
|
items: self
|
||||||
.repositories
|
.repositories
|
||||||
@@ -598,12 +586,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let projection = RepositoryRegistryReader::new(Vec::new()).list();
|
||||||
|
|
||||||
assert!(projection.items.is_empty());
|
assert!(projection.items.is_empty());
|
||||||
assert_eq!(projection.diagnostics.len(), 1);
|
assert!(projection.diagnostics.is_empty());
|
||||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ pub struct WorkerRemovalPlanRequest {
|
|||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum WorkerRemovalBlocker {
|
pub enum WorkerRemovalBlocker {
|
||||||
Hold,
|
Hold,
|
||||||
CurrentAssignment {
|
UnfinishedWork {
|
||||||
assignment_id: String,
|
assignment_id: String,
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
},
|
},
|
||||||
@@ -229,8 +229,8 @@ impl SqliteWorkspaceStore {
|
|||||||
};
|
};
|
||||||
let mut blockers=Vec::new();
|
let mut blockers=Vec::new();
|
||||||
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
|
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()? {
|
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::CurrentAssignment{assignment_id,ticket_id});
|
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
|
||||||
}
|
}
|
||||||
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
||||||
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
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()?;
|
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
|
||||||
return Err(stale_error(&plan,"hold added"));
|
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{
|
if assigned{
|
||||||
mark_stale(&tx,&plan,"current assignment added")?;tx.commit()?;
|
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
|
||||||
return Err(stale_error(&plan,"current assignment added"));
|
return Err(stale_error(&plan,"unfinished work added"));
|
||||||
}
|
}
|
||||||
let now=Utc::now().to_rfc3339();
|
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])?;
|
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()))?;
|
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.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))); }
|
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))?;
|
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:{}:current assignment added",plan.plan_id))); }
|
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
|
||||||
let now=Utc::now().to_rfc3339();
|
let now=Utc::now().to_rfc3339();
|
||||||
if let Some(a)=&result.archive{
|
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()));}
|
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();
|
).map_err(StoreError::from)).unwrap();
|
||||||
assert_eq!(historical, 1);
|
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]
|
#[test]
|
||||||
fn assignment_and_orphan_are_authoritative() {
|
fn assignment_and_orphan_are_authoritative() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
s.with_conn(|c| {
|
seed_current_assignment(&s);
|
||||||
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();
|
|
||||||
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||||
assert!(
|
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 {
|
let runtime_only = WorkerRetentionInventory {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
@@ -1501,6 +1532,355 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(unchanged, 2);
|
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]
|
#[test]
|
||||||
fn concurrent_plan_converges_and_purge_omits_tombstone() {
|
fn concurrent_plan_converges_and_purge_omits_tombstone() {
|
||||||
let s = std::sync::Arc::new(setup());
|
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};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
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 WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
@@ -283,6 +283,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "Backend Job immutable resource serialization",
|
name: "Backend Job immutable resource serialization",
|
||||||
apply: migrate_backend_job_resources_v80_to_v81,
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -379,13 +384,13 @@ pub struct WorkspaceBootstrapRecord {
|
|||||||
pub operation_key: String,
|
pub operation_key: String,
|
||||||
pub request_fingerprint: String,
|
pub request_fingerprint: String,
|
||||||
pub workspace: WorkspaceRecord,
|
pub workspace: WorkspaceRecord,
|
||||||
pub repository: RepositoryRecord,
|
pub repository: Option<RepositoryRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkspaceBootstrapResult {
|
pub struct WorkspaceBootstrapResult {
|
||||||
pub workspace: WorkspaceRecord,
|
pub workspace: WorkspaceRecord,
|
||||||
pub repository: RepositoryRecord,
|
pub repository: Option<RepositoryRecord>,
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
pub replayed: bool,
|
pub replayed: bool,
|
||||||
}
|
}
|
||||||
@@ -1853,6 +1858,26 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
operation_id: &str,
|
operation_id: &str,
|
||||||
) -> Result<()>;
|
) -> 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(
|
fn list_current_ticket_role_assignments(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -1907,6 +1932,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
occurred_at: &str,
|
occurred_at: &str,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> Result<bool>;
|
) -> Result<bool>;
|
||||||
|
fn get_active_ticket_worker_assignment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
) -> Result<Option<TicketCoderAssignmentRecord>>;
|
||||||
fn get_current_ticket_coder_assignment(
|
fn get_current_ticket_coder_assignment(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -4401,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
signing_identity: &WorkspaceSigningIdentityActivation,
|
signing_identity: &WorkspaceSigningIdentityActivation,
|
||||||
identity_provisioning_operation_key: &str,
|
identity_provisioning_operation_key: &str,
|
||||||
) -> Result<WorkspaceBootstrapResult> {
|
) -> 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 {
|
if signing_identity.workspace_id != record.workspace.workspace_id {
|
||||||
return Err(Error::Store(
|
return Err(Error::Store(
|
||||||
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
|
"Workspace signing identity does not belong to the Workspace bootstrap".to_string(),
|
||||||
@@ -4442,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
params![workspace_id],
|
params![workspace_id],
|
||||||
read_workspace_record,
|
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,
|
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
|
||||||
source_kind, source_uri, default_ref, source_revision,
|
source_kind, source_uri, default_ref, source_revision,
|
||||||
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
source_fingerprint, observed_status, observed_at, created_at, updated_at
|
||||||
FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
|
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,
|
read_repository_record,
|
||||||
)?;
|
).optional()? } else { None };
|
||||||
let persisted_identity = tx.query_row(
|
let persisted_identity = tx.query_row(
|
||||||
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
r#"SELECT workspace_id, key_id, algorithm, public_key,
|
||||||
public_key_fingerprint, private_material_ref, revision, state,
|
public_key_fingerprint, private_material_ref, revision, state,
|
||||||
@@ -4536,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.workspace.updated_at,
|
record.workspace.updated_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
if let Some(repository) = &record.repository {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"INSERT INTO repositories (
|
r#"INSERT INTO repositories (
|
||||||
workspace_id, repository_id, repository_key, kind, provider, uri,
|
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
|
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)"#,
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
|
||||||
params![
|
params![
|
||||||
record.repository.workspace_id,
|
repository.workspace_id,
|
||||||
record.repository.repository_id,
|
repository.repository_id,
|
||||||
record.repository.repository_key,
|
repository.repository_key,
|
||||||
record.repository.kind,
|
repository.kind,
|
||||||
record.repository.provider,
|
repository.provider,
|
||||||
record.repository.source.uri,
|
repository.source.uri,
|
||||||
record.repository.source.kind.as_str(),
|
repository.source.kind.as_str(),
|
||||||
record.repository.source.uri,
|
repository.source.uri,
|
||||||
record.repository.default_ref,
|
repository.default_ref,
|
||||||
record.repository.source_revision,
|
repository.source_revision,
|
||||||
record.repository.source_fingerprint,
|
repository.source_fingerprint,
|
||||||
record.repository.observed_status.as_str(),
|
repository.observed_status.as_str(),
|
||||||
record.repository.observed_at,
|
repository.observed_at,
|
||||||
record.repository.created_at,
|
repository.created_at,
|
||||||
record.repository.updated_at,
|
repository.updated_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() {
|
if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() {
|
||||||
let state = crate::config_source::initial_state()?;
|
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(
|
fn list_current_ticket_role_assignments(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -8203,12 +8305,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
Some(expected.clone())
|
Some(expected.clone())
|
||||||
} else if record.role.is_singleton() && !current.is_empty() {
|
} else if record.role.is_singleton() && !current.is_empty() {
|
||||||
return Err(Error::TicketAssignmentConflict(format!(
|
let active: bool = tx.query_row(
|
||||||
"Ticket `{}` already has an active `{}` assignment `{}`",
|
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)",
|
||||||
record.ticket_id,
|
params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?;
|
||||||
record.role.as_str(),
|
if active {
|
||||||
current[0].assignment_id
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -8434,7 +8539,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let conflicting: i64 = tx.query_row(
|
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
|
WHERE workspace_id = ?1 AND ticket_id = ?2
|
||||||
AND role IN ('orchestrator', 'coder')",
|
AND role IN ('orchestrator', 'coder')",
|
||||||
params![record.workspace_id, record.ticket_id],
|
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(
|
tx.execute(
|
||||||
"INSERT INTO ticket_assignment_operations (
|
"INSERT INTO ticket_assignment_operations (
|
||||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||||
@@ -8478,10 +8588,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO ticket_worker_assignment_events (
|
"INSERT INTO ticket_worker_assignment_events (
|
||||||
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
||||||
actor, created_at, operation_id
|
actor, created_at, operation_id, previous_assignment_id
|
||||||
) VALUES (?1, ?2, 'coder', ?3, 'assigned', ?4, ?5, ?6, ?7)",
|
) VALUES (?1, ?2, 'coder', ?3, ?8, ?4, ?5, ?6, ?7, ?9)",
|
||||||
params![record.workspace_id, record.ticket_id, event_id, record.assignment_id,
|
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(
|
let event_index: i64 = tx.query_row(
|
||||||
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events
|
"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(
|
fn get_current_ticket_coder_assignment(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -8753,7 +8885,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
read_ticket_worker_assignment_record,
|
read_ticket_worker_assignment_record,
|
||||||
)
|
)
|
||||||
.optional()?;
|
.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!(
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
"Ticket {} is already assigned; use the explicit reassign operation",
|
"Ticket {} is already assigned; use the explicit reassign operation",
|
||||||
record.ticket_id
|
record.ticket_id
|
||||||
@@ -8790,7 +8925,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.assigned_at,
|
record.assigned_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let current_write = if allow_reassign {
|
let current_write = if previous.is_some() {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"UPDATE ticket_current_worker_assignments
|
r#"UPDATE ticket_current_worker_assignments
|
||||||
SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6
|
SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6
|
||||||
@@ -10633,7 +10768,7 @@ fn runtime_removal_preflight(
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"active_worker_assignment",
|
"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",
|
"active_workdir",
|
||||||
@@ -11743,6 +11878,13 @@ fn read_ticket_role_assignment_by_id(
|
|||||||
.optional()?)
|
.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 {
|
fn ticket_role_assignment_select_sql(where_clause: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.role, \
|
"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 {
|
fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: &str) -> Error {
|
||||||
if matches!(error, rusqlite::Error::SqliteFailure(_, _)) {
|
if matches!(error, rusqlite::Error::SqliteFailure(_, _)) {
|
||||||
Error::TicketAssignmentConflict(format!(
|
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 {
|
} else {
|
||||||
Error::Sqlite(error)
|
Error::Sqlite(error)
|
||||||
@@ -15128,6 +15270,99 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> {
|
||||||
if current_schema_version(conn)? != 80 {
|
if current_schema_version(conn)? != 80 {
|
||||||
return Err(Error::Store(
|
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| {
|
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||||
Error::Store(format!(
|
Error::Store(format!(
|
||||||
"fresh Workspace Ticket schema verification failed: {error}"
|
"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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17212,6 +17474,9 @@ mod tests {
|
|||||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.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)?;
|
downgrade_schema_66_ticket_and_workdir_authority(conn)?;
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"DROP TRIGGER runtime_binding_insert_blocked_by_removal; \
|
"DROP TRIGGER runtime_binding_insert_blocked_by_removal; \
|
||||||
@@ -17281,6 +17546,11 @@ mod tests {
|
|||||||
drop(SqliteWorkspaceStore::open(&path).unwrap());
|
drop(SqliteWorkspaceStore::open(&path).unwrap());
|
||||||
{
|
{
|
||||||
let conn = Connection::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();
|
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"ALTER TABLE worker_removal_operations \
|
"ALTER TABLE worker_removal_operations \
|
||||||
@@ -17659,6 +17929,44 @@ mod tests {
|
|||||||
.unwrap();
|
.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) {
|
fn prepare_schema_v52(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
configure_sqlite(&conn).unwrap();
|
configure_sqlite(&conn).unwrap();
|
||||||
@@ -17667,6 +17975,7 @@ mod tests {
|
|||||||
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
|
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
create_latest_workspace_schema(&conn).unwrap();
|
create_latest_workspace_schema(&conn).unwrap();
|
||||||
|
downgrade_assignment_work_schema(&conn);
|
||||||
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
||||||
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -17928,6 +18237,10 @@ mod tests {
|
|||||||
version: 81,
|
version: 81,
|
||||||
name: "Backend Job immutable resource serialization".to_string()
|
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()),
|
(79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()),
|
||||||
(80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()),
|
(80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()),
|
||||||
(81, "Backend Job immutable resource serialization".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")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -18370,7 +18684,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
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();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -18379,7 +18693,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
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", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||||
@@ -20473,6 +20787,203 @@ mod tests {
|
|||||||
assert_eq!(pinned.definition.name, "coder-review");
|
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]
|
#[tokio::test]
|
||||||
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -21230,7 +21741,7 @@ INSERT INTO worker_registry (
|
|||||||
operation_key: "invalid-key".to_string(),
|
operation_key: "invalid-key".to_string(),
|
||||||
request_fingerprint: "sha256:invalid-key".to_string(),
|
request_fingerprint: "sha256:invalid-key".to_string(),
|
||||||
workspace: workspace.clone(),
|
workspace: workspace.clone(),
|
||||||
repository,
|
repository: Some(repository),
|
||||||
},
|
},
|
||||||
&signing_identity,
|
&signing_identity,
|
||||||
"identity-store-test",
|
"identity-store-test",
|
||||||
@@ -21269,7 +21780,7 @@ INSERT INTO worker_registry (
|
|||||||
operation_key: "create-workspace".to_string(),
|
operation_key: "create-workspace".to_string(),
|
||||||
request_fingerprint: "sha256:create-workspace".to_string(),
|
request_fingerprint: "sha256:create-workspace".to_string(),
|
||||||
workspace,
|
workspace,
|
||||||
repository: valid_repository,
|
repository: Some(valid_repository),
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.reserve_workspace_signing_identity_provisioning(
|
.reserve_workspace_signing_identity_provisioning(
|
||||||
@@ -21296,7 +21807,7 @@ INSERT INTO worker_registry (
|
|||||||
);
|
);
|
||||||
let mut duplicate = first;
|
let mut duplicate = first;
|
||||||
duplicate.operation_key = "duplicate-workspace".to_string();
|
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
|
let error = store
|
||||||
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
|
.create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test")
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
|
|||||||
@@ -89,14 +89,7 @@ impl TicketItemCheckFindingCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn item_revision(ticket: &ticket::Ticket) -> String {
|
pub fn item_revision(ticket: &ticket::Ticket) -> String {
|
||||||
ticket
|
ticket::ticket_item_revision(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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request(
|
pub fn request(
|
||||||
|
|||||||
@@ -356,10 +356,10 @@ impl SqliteWorkspaceStore {
|
|||||||
params![operation.workspace_id, operation.working_directory_id],
|
params![operation.workspace_id, operation.working_directory_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
let current_assignment: bool = conn.query_row(
|
let unfinished_work: bool = conn.query_row(
|
||||||
r#"SELECT EXISTS(
|
r#"SELECT EXISTS(
|
||||||
SELECT 1 FROM worker_workdir_links AS link
|
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
|
ON current.workspace_id=link.workspace_id
|
||||||
JOIN ticket_worker_assignments AS assignment
|
JOIN ticket_worker_assignments AS assignment
|
||||||
ON assignment.workspace_id=current.workspace_id
|
ON assignment.workspace_id=current.workspace_id
|
||||||
@@ -404,10 +404,10 @@ impl SqliteWorkspaceStore {
|
|||||||
detail: "Workdir has a pending Worker attachment reservation",
|
detail: "Workdir has a pending Worker attachment reservation",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if current_assignment {
|
if unfinished_work {
|
||||||
guards.push(WorkdirRemovalGuard {
|
guards.push(WorkdirRemovalGuard {
|
||||||
category: "current_assignment",
|
category: "unfinished_work",
|
||||||
detail: "Workdir is bound to a Worker with a current Ticket assignment",
|
detail: "Workdir is bound to a Worker with unfinished Ticket work",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if retention_hold {
|
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]
|
#[tokio::test]
|
||||||
async fn retained_completion_keeps_registry_and_is_auditable() {
|
async fn retained_completion_keeps_registry_and_is_auditable() {
|
||||||
let (store, workdir) = seeded_store().await;
|
let (store, workdir) = seeded_store().await;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200;
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct WorkspaceCreateResult {
|
pub struct WorkspaceCreateResult {
|
||||||
pub workspace: WorkspaceRecord,
|
pub workspace: WorkspaceRecord,
|
||||||
pub repository: RepositoryRecord,
|
pub repository: Option<RepositoryRecord>,
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
pub request_fingerprint: String,
|
pub request_fingerprint: String,
|
||||||
pub replayed: bool,
|
pub replayed: bool,
|
||||||
@@ -94,19 +94,23 @@ impl WorkspaceCatalogService {
|
|||||||
)?;
|
)?;
|
||||||
let display_name =
|
let display_name =
|
||||||
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
|
||||||
let repository_source = validate_repository_source(&request.repository.uri)?;
|
let repository_intent = request
|
||||||
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
|
|
||||||
.repository
|
.repository
|
||||||
.default_ref
|
.map(|repository| {
|
||||||
.as_deref()
|
let source = validate_repository_source(&repository.uri)?;
|
||||||
.map(str::trim)
|
server_api::validate_repository_key(&repository.repository_key).map_err(
|
||||||
.filter(|value| !value.is_empty())
|
|error| Error::InvalidInput(format!("invalid Repository key: {error}")),
|
||||||
.unwrap_or("HEAD")
|
)?;
|
||||||
.to_string();
|
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
|
let requested_workspace_id = requested_workspace_id
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
Uuid::parse_str(value.trim())
|
Uuid::parse_str(value.trim())
|
||||||
@@ -121,9 +125,9 @@ impl WorkspaceCatalogService {
|
|||||||
requested_workspace_id.as_deref(),
|
requested_workspace_id.as_deref(),
|
||||||
&display_name,
|
&display_name,
|
||||||
Some(&owner_account_id),
|
Some(&owner_account_id),
|
||||||
&repository_key,
|
repository_intent.as_ref().map(|(key, source, selector)| {
|
||||||
&repository_uri,
|
(key.as_str(), source.uri.as_str(), selector.as_str())
|
||||||
&default_ref,
|
}),
|
||||||
);
|
);
|
||||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
let (signing_identity, identity_provisioning_operation_key) =
|
let (signing_identity, identity_provisioning_operation_key) =
|
||||||
@@ -146,21 +150,23 @@ impl WorkspaceCatalogService {
|
|||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now.clone(),
|
updated_at: now.clone(),
|
||||||
},
|
},
|
||||||
repository: RepositoryRecord {
|
repository: repository_intent.map(
|
||||||
workspace_id,
|
|(repository_key, repository_source, default_ref)| RepositoryRecord {
|
||||||
repository_id: Uuid::now_v7().to_string(),
|
workspace_id,
|
||||||
repository_key: repository_key.clone(),
|
repository_id: Uuid::now_v7().to_string(),
|
||||||
kind: "git".to_string(),
|
repository_key: repository_key.clone(),
|
||||||
provider: Some("git".to_string()),
|
kind: "git".to_string(),
|
||||||
source: repository_source.clone(),
|
provider: Some("git".to_string()),
|
||||||
default_ref: Some(default_ref),
|
source: repository_source.clone(),
|
||||||
source_revision: 1,
|
default_ref: Some(default_ref),
|
||||||
source_fingerprint: repository_source_fingerprint(&repository_source),
|
source_revision: 1,
|
||||||
observed_status: RepositoryObservedStatus::Unverified,
|
source_fingerprint: repository_source_fingerprint(&repository_source),
|
||||||
observed_at: None,
|
observed_status: RepositoryObservedStatus::Unverified,
|
||||||
created_at: now.clone(),
|
observed_at: None,
|
||||||
updated_at: now,
|
created_at: now.clone(),
|
||||||
},
|
updated_at: now,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
&signing_identity,
|
&signing_identity,
|
||||||
&identity_provisioning_operation_key,
|
&identity_provisioning_operation_key,
|
||||||
@@ -193,20 +199,19 @@ fn workspace_create_fingerprint(
|
|||||||
requested_workspace_id: Option<&str>,
|
requested_workspace_id: Option<&str>,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
owner_account_id: Option<&str>,
|
owner_account_id: Option<&str>,
|
||||||
repository_key: &str,
|
repository: Option<(&str, &str, &str)>,
|
||||||
repository_uri: &str,
|
|
||||||
default_ref: &str,
|
|
||||||
) -> String {
|
) -> String {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"requested_workspace_id": requested_workspace_id,
|
"requested_workspace_id": requested_workspace_id,
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
"owner_account_id": owner_account_id,
|
"owner_account_id": owner_account_id,
|
||||||
"repository": {
|
// Preserve repository-present fingerprint bytes for persisted create receipts.
|
||||||
"repository_key": repository_key,
|
"repository": repository.map(|(key, uri, selector)| serde_json::json!({
|
||||||
"uri": repository_uri,
|
"repository_key": key,
|
||||||
"default_ref": default_ref,
|
"uri": uri,
|
||||||
|
"default_ref": selector,
|
||||||
"kind": "git",
|
"kind": "git",
|
||||||
}
|
}))
|
||||||
});
|
});
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
|
hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes"));
|
||||||
@@ -294,11 +299,11 @@ mod tests {
|
|||||||
let request = WorkspaceCreateRequest {
|
let request = WorkspaceCreateRequest {
|
||||||
operation_key: "request-1".to_string(),
|
operation_key: "request-1".to_string(),
|
||||||
display_name: "Workspace A".to_string(),
|
display_name: "Workspace A".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: "platform".to_string(),
|
repository_key: "platform".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
@@ -354,11 +359,11 @@ mod tests {
|
|||||||
let request = WorkspaceCreateRequest {
|
let request = WorkspaceCreateRequest {
|
||||||
operation_key: operation_key.to_string(),
|
operation_key: operation_key.to_string(),
|
||||||
display_name: "Workspace A".to_string(),
|
display_name: "Workspace A".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: repository_key.to_string(),
|
repository_key: repository_key.to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let error = service
|
let error = service
|
||||||
@@ -373,36 +378,37 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
for with_repository in [false, true] {
|
||||||
let database_path = temp.path().join("server.db");
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
let database_path = temp.path().join("server.db");
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(
|
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||||
store.clone(),
|
let service = WorkspaceCatalogService::new(
|
||||||
Arc::new(FailFirstMaterialWrite {
|
store.clone(),
|
||||||
inner: materials.clone(),
|
Arc::new(FailFirstMaterialWrite {
|
||||||
fail: AtomicBool::new(true),
|
inner: materials.clone(),
|
||||||
}),
|
fail: AtomicBool::new(true),
|
||||||
);
|
}),
|
||||||
let repository = git_repository();
|
);
|
||||||
let request = WorkspaceCreateRequest {
|
let repository = git_repository();
|
||||||
operation_key: "material-failure".to_string(),
|
let request = WorkspaceCreateRequest {
|
||||||
display_name: "Workspace A".to_string(),
|
operation_key: "material-failure".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
display_name: "Workspace A".to_string(),
|
||||||
uri: repository.path().display().to_string(),
|
repository: with_repository.then(|| InitialRepositoryIntent {
|
||||||
repository_key: "main".to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
default_ref: None,
|
repository_key: "main".to_string(),
|
||||||
},
|
default_ref: None,
|
||||||
};
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
service
|
service
|
||||||
.create(request.clone(), owner_account_id.clone())
|
.create(request.clone(), owner_account_id.clone())
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(store.list_workspaces().unwrap().is_empty());
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
let reserved_key = store
|
let reserved_key = store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
|
"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();
|
.unwrap();
|
||||||
|
|
||||||
drop(service);
|
drop(service);
|
||||||
drop(store);
|
drop(store);
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||||
let created = restarted.create(request, owner_account_id).unwrap();
|
let created = restarted.create(request, owner_account_id).unwrap();
|
||||||
let identity = store
|
let identity = store
|
||||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(identity.key_id, reserved_key);
|
assert_eq!(identity.key_id, reserved_key);
|
||||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
|
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
for with_repository in [false, true] {
|
||||||
let database_path = temp.path().join("server.db");
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
let database_path = temp.path().join("server.db");
|
||||||
let owner_account_id = owner_account(store.as_ref());
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
|
||||||
let repository = git_repository();
|
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
|
||||||
let request = WorkspaceCreateRequest {
|
let repository = git_repository();
|
||||||
operation_key: "db-failure".to_string(),
|
let request = WorkspaceCreateRequest {
|
||||||
display_name: "Workspace A".to_string(),
|
operation_key: "db-failure".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
display_name: "Workspace A".to_string(),
|
||||||
uri: repository.path().display().to_string(),
|
repository: with_repository.then(|| InitialRepositoryIntent {
|
||||||
repository_key: "main".to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
default_ref: None,
|
repository_key: "main".to_string(),
|
||||||
},
|
default_ref: None,
|
||||||
};
|
}),
|
||||||
store
|
};
|
||||||
.with_conn(|conn| {
|
store
|
||||||
conn.execute_batch(
|
.with_conn(|conn| {
|
||||||
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
conn.execute_batch(
|
||||||
|
r#"CREATE TRIGGER fail_workspace_create_identity_audit
|
||||||
BEFORE INSERT ON workspace_signing_identity_audit
|
BEFORE INSERT ON workspace_signing_identity_audit
|
||||||
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
service
|
service
|
||||||
.create(request.clone(), owner_account_id.clone())
|
.create(request.clone(), owner_account_id.clone())
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(store.list_workspaces().unwrap().is_empty());
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
let (reserved_key, material_ref) = store
|
let (reserved_key, material_ref) = store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.query_row(
|
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'",
|
"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)
|
.map_err(Error::from)
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(materials.load(&material_ref).unwrap().is_some());
|
assert!(materials.load(&material_ref).unwrap().is_some());
|
||||||
store
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
drop(service);
|
drop(service);
|
||||||
drop(store);
|
drop(store);
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
|
||||||
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
|
||||||
let created = restarted.create(request, owner_account_id).unwrap();
|
let created = restarted.create(request, owner_account_id).unwrap();
|
||||||
let identity = store
|
let identity = store
|
||||||
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
.get_workspace_signing_identity(&created.workspace.workspace_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(identity.key_id, reserved_key);
|
assert_eq!(identity.key_id, reserved_key);
|
||||||
assert_eq!(identity.state, "active");
|
assert_eq!(identity.state, "active");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -506,11 +515,11 @@ mod tests {
|
|||||||
let mut request = WorkspaceCreateRequest {
|
let mut request = WorkspaceCreateRequest {
|
||||||
operation_key: "request-1".to_string(),
|
operation_key: "request-1".to_string(),
|
||||||
display_name: "Workspace A".to_string(),
|
display_name: "Workspace A".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
service
|
service
|
||||||
.create(request.clone(), owner_account_id.clone())
|
.create(request.clone(), owner_account_id.clone())
|
||||||
@@ -524,6 +533,199 @@ mod tests {
|
|||||||
assert!(error.contains("different input"), "{error}");
|
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]
|
#[test]
|
||||||
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
fn repository_intent_accepts_unavailable_local_sources_without_server_io() {
|
||||||
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
let remote = validate_repository_source("https://example.test/repo.git").unwrap();
|
||||||
@@ -564,11 +766,11 @@ mod tests {
|
|||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
operation_key: "organization-owner-create".to_string(),
|
operation_key: "organization-owner-create".to_string(),
|
||||||
display_name: "Organization Workspace".to_string(),
|
display_name: "Organization Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository.path().display().to_string(),
|
uri: repository.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
"organization-owner".to_string(),
|
"organization-owner".to_string(),
|
||||||
)
|
)
|
||||||
@@ -605,11 +807,11 @@ mod tests {
|
|||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
operation_key: "owner-a-create".to_string(),
|
operation_key: "owner-a-create".to_string(),
|
||||||
display_name: "Owner A Workspace".to_string(),
|
display_name: "Owner A Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository_a.path().display().to_string(),
|
uri: repository_a.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
owner_a.clone(),
|
owner_a.clone(),
|
||||||
)
|
)
|
||||||
@@ -619,11 +821,11 @@ mod tests {
|
|||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
operation_key: "owner-b-create".to_string(),
|
operation_key: "owner-b-create".to_string(),
|
||||||
display_name: "Owner B Workspace".to_string(),
|
display_name: "Owner B Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: repository_b.path().display().to_string(),
|
uri: repository_b.path().display().to_string(),
|
||||||
repository_key: "main".to_string(),
|
repository_key: "main".to_string(),
|
||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
owner_b.clone(),
|
owner_b.clone(),
|
||||||
)
|
)
|
||||||
@@ -660,11 +862,11 @@ mod tests {
|
|||||||
WorkspaceCreateRequest {
|
WorkspaceCreateRequest {
|
||||||
operation_key: "remote-create".to_string(),
|
operation_key: "remote-create".to_string(),
|
||||||
display_name: "Remote Workspace".to_string(),
|
display_name: "Remote Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: Some(InitialRepositoryIntent {
|
||||||
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
uri: "ssh://git@example.test/org/repository.git".to_string(),
|
||||||
repository_key: "remote".to_string(),
|
repository_key: "remote".to_string(),
|
||||||
default_ref: Some("main".to_string()),
|
default_ref: Some("main".to_string()),
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
owner_account_id,
|
owner_account_id,
|
||||||
)
|
)
|
||||||
@@ -673,7 +875,7 @@ mod tests {
|
|||||||
let persisted = store
|
let persisted = store
|
||||||
.get_repository(
|
.get_repository(
|
||||||
&result.workspace.workspace_id,
|
&result.workspace.workspace_id,
|
||||||
&result.repository.repository_id,
|
&result.repository.as_ref().unwrap().repository_id,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"repository_ssh_host_trusts",
|
"repository_ssh_host_trusts",
|
||||||
"server_secret_versions",
|
"server_secret_versions",
|
||||||
"ticket_assignment_operations",
|
"ticket_assignment_operations",
|
||||||
|
"ticket_assignment_work_releases",
|
||||||
"ticket_assignment_ticket_tombstones",
|
"ticket_assignment_ticket_tombstones",
|
||||||
"ticket_assignment_worker_tombstones",
|
"ticket_assignment_worker_tombstones",
|
||||||
"ticket_current_worker_assignments",
|
"ticket_current_worker_assignments",
|
||||||
@@ -686,10 +687,10 @@ fn workspace_database_blockers(
|
|||||||
|
|
||||||
for (sql, kind, resource_kind, message) in [
|
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,
|
WorkspaceDeletionBlockerKind::WorkerRemovalBlocked,
|
||||||
"ticket",
|
"ticket",
|
||||||
"Remove current Ticket assignments before deleting the Workspace.",
|
"End unfinished Ticket work before deleting the Workspace.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"SELECT COUNT(*)
|
"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.
|
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
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
|
- [`../../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(),
|
"--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 {
|
if explicit_local {
|
||||||
return resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget);
|
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(
|
return resolver.resolve_connection(
|
||||||
command,
|
command,
|
||||||
CliConnectionInput::BackendTarget {
|
CliConnectionInput::BackendTarget {
|
||||||
|
|||||||
+322
-12
@@ -27,7 +27,8 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tui::{BackendWorkerPickerIntent, LaunchMode, LaunchOptions};
|
use tui::{BackendWorkerPickerIntent, LaunchMode, LaunchOptions};
|
||||||
use workspace_bootstrap::{
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -56,6 +57,8 @@ enum Mode {
|
|||||||
no_wait: bool,
|
no_wait: bool,
|
||||||
},
|
},
|
||||||
Init(InitOptions),
|
Init(InitOptions),
|
||||||
|
WorkspaceHelp,
|
||||||
|
WorkspaceCreate(WorkspaceCreateOptions),
|
||||||
WorkdirHelp,
|
WorkdirHelp,
|
||||||
WorkdirShare(workdir_share::WorkdirShareOptions),
|
WorkdirShare(workdir_share::WorkdirShareOptions),
|
||||||
WorkerRuntime(Vec<String>),
|
WorkerRuntime(Vec<String>),
|
||||||
@@ -123,10 +126,7 @@ async fn run(mode: Mode) -> ExitCode {
|
|||||||
},
|
},
|
||||||
Mode::Init(options) => match run_init(options).await {
|
Mode::Init(options) => match run_init(options).await {
|
||||||
Ok(workspace) => {
|
Ok(workspace) => {
|
||||||
println!(
|
println!("{}", workspace_creation_message(&workspace));
|
||||||
"Initialized Workspace '{}' for repository '{}'",
|
|
||||||
workspace.workspace.display_name, workspace.repository.repository_key
|
|
||||||
);
|
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -134,6 +134,20 @@ async fn run(mode: Mode) -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
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 => {
|
Mode::WorkdirHelp => {
|
||||||
print_workdir_help();
|
print_workdir_help();
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
@@ -278,7 +292,7 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
|||||||
command,
|
command,
|
||||||
true,
|
true,
|
||||||
None,
|
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()))?;
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
return Ok(Mode::Ticket { cli, target });
|
return Ok(Mode::Ticket { cli, target });
|
||||||
}
|
}
|
||||||
|
"workspace" => {
|
||||||
|
return parse_workspace_args(&args[1..], &target_selection);
|
||||||
|
}
|
||||||
"init" => {
|
"init" => {
|
||||||
if target_selection.explicit_local {
|
if target_selection.explicit_local {
|
||||||
return Err(ParseError(
|
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(
|
fn parse_init_args(
|
||||||
args: &[String],
|
args: &[String],
|
||||||
explicit_backend_url: Option<String>,
|
explicit_backend_url: Option<String>,
|
||||||
@@ -1989,6 +2068,7 @@ Usage:
|
|||||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
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>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||||
yoi [--backend <URL>] login [--no-wait]
|
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]
|
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:
|
Target selection:
|
||||||
--local Use the client-owned one-process Standalone host
|
--local Use the client-owned one-process Standalone host
|
||||||
--backend <URL> Use a Workspace Backend explicitly
|
--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.
|
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
|
`default_connection = "local"` selects Standalone; it does not enable a filesystem
|
||||||
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
|
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
|
||||||
|
|
||||||
@@ -2017,6 +2099,7 @@ Console options:
|
|||||||
--worker-id <ID> Backend Worker id; requires --runtime-id
|
--worker-id <ID> Backend Worker id; requires --runtime-id
|
||||||
|
|
||||||
Host commands:
|
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 init Register the current Git repository as a new Backend Workspace.
|
||||||
yoi workdir share Interactively share a client-hosted External Workdir.
|
yoi workdir share Interactively share a client-hosted External Workdir.
|
||||||
keys Manage local model/API keys
|
keys Manage local model/API keys
|
||||||
@@ -2058,7 +2141,7 @@ Authority:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--backend <URL> Use this Workspace Backend
|
--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
|
--workspace <PATH> Match this Git repository against Server DB Repository records
|
||||||
-r, --stopped List stopped Backend Workers
|
-r, --stopped List stopped Backend Workers
|
||||||
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
|
||||||
@@ -2077,7 +2160,7 @@ Usage:
|
|||||||
Target options:
|
Target options:
|
||||||
--local Restore from the client-owned Standalone Worker store
|
--local Restore from the client-owned Standalone Worker store
|
||||||
--backend <URL> Restore a stopped Backend Workspace Worker
|
--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:
|
Options:
|
||||||
--workspace <PATH> Scope Standalone Workers to this cwd identity (defaults to cwd)
|
--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("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("yoi server"));
|
||||||
assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS"));
|
assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS"));
|
||||||
}
|
}
|
||||||
@@ -3174,6 +3258,232 @@ backend = "shared"
|
|||||||
assert!(!error.contains("stale-local-workspace"));
|
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]
|
#[test]
|
||||||
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
|
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
|
||||||
let repository = tempfile::tempdir().unwrap();
|
let repository = tempfile::tempdir().unwrap();
|
||||||
@@ -3398,9 +3708,9 @@ backend = "shared"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ pub(crate) struct InitOptions {
|
|||||||
pub(crate) default_ref: Option<String>,
|
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(
|
pub(crate) async fn run_init(
|
||||||
options: InitOptions,
|
options: InitOptions,
|
||||||
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
|
) -> Result<server_api::WorkspaceCreateResponse, ParseError> {
|
||||||
@@ -26,27 +33,56 @@ pub(crate) async fn run_init(
|
|||||||
.repository_root
|
.repository_root
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
|
.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 {
|
let target = BackendWorkspaceCatalogTarget {
|
||||||
base_url: options.backend_url.clone(),
|
base_url: options.backend_url.clone(),
|
||||||
};
|
};
|
||||||
let response = create_backend_workspace(
|
let request = workspace_create_request(options.display_name, options.repository);
|
||||||
&target,
|
let response = create_backend_workspace(&target, &request)
|
||||||
&CreateBackendWorkspaceRequest {
|
.await
|
||||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
|
||||||
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}")))?;
|
|
||||||
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
|
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
|
||||||
Ok(response)
|
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(
|
pub(crate) fn select_backend_workspace_for_repository(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
repository_path: &Path,
|
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]
|
#[test]
|
||||||
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
|
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
|
||||||
let repository = tempfile::tempdir().unwrap();
|
let repository = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Collection interface `yoi.merge-request/collection/v1`:
|
|||||||
| Native operation | Existing Tool | Preserved authority inputs |
|
| Native operation | Existing Tool | Preserved authority inputs |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `open` | `OpenMergeRequest` | Ticket, repository key, source selector, target selector, summary |
|
| `open` | `OpenMergeRequest` | Ticket, repository key, source selector, target selector, summary |
|
||||||
| `complete_ticket` | `CompleteTicket` | Ticket, operation ID, current item revision, exact linked-MR result set, requirement approval event |
|
|
||||||
|
|
||||||
Item interface `yoi.merge-request/item/v1`:
|
Item interface `yoi.merge-request/item/v1`:
|
||||||
|
|
||||||
@@ -39,9 +38,9 @@ Typical role surfaces are therefore:
|
|||||||
|
|
||||||
- Coder: collection `open`; item `read`.
|
- Coder: collection `open`; item `read`.
|
||||||
- Reviewer: item `read` and `review`.
|
- Reviewer: item `read` and `review`.
|
||||||
- Orchestrator: collection `complete_ticket`; item `read`, `check_readiness`, and `complete`.
|
- Orchestrator: item `read`, `check_readiness`, and `complete`.
|
||||||
|
|
||||||
`complete_ticket` is deliberately collection-scoped because its subject is a Ticket and an exact set of linked Merge Request results rather than one route-bound MR. It keeps those identities explicit. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
|
`CompleteTicket` belongs to the Ticket Feature and route-bound Ticket item `complete`, alongside state and close decisions. It takes current item/state CAS, a replay key, reason, and optional references; it requires neither an MR result set nor a Reviewer approval. The former MR collection `complete_ticket` operation is not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
|
||||||
|
|
||||||
## Preserved review and integration gates
|
## Preserved review and integration gates
|
||||||
|
|
||||||
@@ -55,8 +54,8 @@ The native projection delegates to the same ordinary tools and REST endpoints. I
|
|||||||
- source movement invalidating source approval while target-only movement preserves unchanged-source review;
|
- source movement invalidating source approval while target-only movement preserves unchanged-source review;
|
||||||
- unresolved request-change and revoked/stale approval rejection;
|
- unresolved request-change and revoked/stale approval rejection;
|
||||||
- current target observation, target-before/after proof, strategy, and conflict-resolution checks;
|
- current target observation, target-before/after proof, strategy, and conflict-resolution checks;
|
||||||
- Orchestrator-only Merge Request and Ticket completion;
|
- Orchestrator-only Merge Request integration;
|
||||||
- immutable integration/result evidence and exact linked-result-set Ticket completion.
|
- immutable integration/result evidence and exact linked-result-set requirement attestation.
|
||||||
|
|
||||||
The Worldspace adapter performs no Git or provider integration. A visible `complete` operation remains unusable without the ordinary Backend's authenticated online Orchestrator source and integration evidence.
|
The Worldspace adapter performs no Git or provider integration. A visible `complete` operation remains unusable without the ordinary Backend's authenticated online Orchestrator source and integration evidence.
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ The Worldspace adapter performs no Git or provider integration. A visible `compl
|
|||||||
|
|
||||||
`read` exposes the existing bounded thread page with optional `after` and a `1..=200` limit. Responses keep linked Tickets as references/data, so Ticket or Objective native projections are not prerequisites. Descriptors never expand repository diffs, unbounded thread history, credentials, or capability material.
|
`read` exposes the existing bounded thread page with optional `after` and a `1..=200` limit. Responses keep linked Tickets as references/data, so Ticket or Objective native projections are not prerequisites. Descriptors never expand repository diffs, unbounded thread history, credentials, or capability material.
|
||||||
|
|
||||||
A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket completion invalidates every MR route named in its exact result set because releasing the assignment can change later ref observations. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again.
|
A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket decisions advance the Ticket provider's object generation, not the MR integration state or proofs. Ending Ticket work can affect later live source observation, so clients reread MR authority before MR actions; Backend checks remain authoritative. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again.
|
||||||
|
|
||||||
Optional fields preserve ordinary JSON-input semantics: omission remains omission and explicit WIP unit/null remains JSON `null` for schema and permission matching. The reconstructed object is validated against the original Tool JSON Schema before the Tool executes.
|
Optional fields preserve ordinary JSON-input semantics: omission remains omission and explicit WIP unit/null remains JSON `null` for schema and permission matching. The reconstructed object is validated against the original Tool JSON Schema before the Tool executes.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Ticket evidence and immutable Merge Request results
|
||||||
|
|
||||||
|
Ticket lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded `done` state or a `CompleteTicket` event.
|
||||||
|
|
||||||
|
## Evidence subjects
|
||||||
|
|
||||||
|
- An open MR uses the Repository/Runtime authority's provider-resolved current source. No Server-local checkout is assumed. Failure is a tagged `source_ref_observation: { status: "unavailable", code }`, not proof that review is stale. Ticket review status is `unknown`; `missing` includes `source_ref_unavailable`, not `review_after_rescope` merely because observation failed.
|
||||||
|
- A merged MR uses its stored MergeResult's `approved_source_ref` and `approval_event_id`. Branch deletion/movement, checkout absence, and provider outage do not trigger source observation. The summary's `current_subject_ref` is the immutable merged source and observation is `not_required`.
|
||||||
|
- Integration approval must exist, approve that exact source, predate the merge, have matching ReviewRequested evidence, and not have been revoked. `integration_evidence_error` is a bounded machine code for invalid stored evidence. `merged` alone never creates approval.
|
||||||
|
|
||||||
|
A merged MR's review status/timestamps describe **integration approval**, not a later requirement approval. Paged MR detail reads evaluate the full stored result before paging; UI must not infer approval from a merge event appearing in one page.
|
||||||
|
|
||||||
|
## Current requirements
|
||||||
|
|
||||||
|
`merge_request::requirement_approval` evaluates Ticket read-side MR evidence; it is not a Ticket completion gate. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval.
|
||||||
|
|
||||||
|
`approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`.
|
||||||
|
|
||||||
|
`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done.
|
||||||
|
|
||||||
|
CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item revision/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts.
|
||||||
|
|
||||||
|
Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used.
|
||||||
|
|
||||||
|
Query candidate SQL narrows Ticket metadata, not live evidence. Evidence predicates are evaluated from the same full MR projection as ShowTicket, after source observation. Bounded scans can return an empty page with `has_more` and a cursor after the last scanned candidate; callers must follow that cursor rather than treat an empty page as exhaustion. This prevents stale SQL/report heuristics or early nonmatching candidates from hiding later evidence matches.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Workspace creation with optional Repository intent
|
||||||
|
|
||||||
|
A Workspace is usable without a Repository. Its initial Git Repository is an
|
||||||
|
optional asset, not an identity or a prerequisite for connection. Creation does
|
||||||
|
not initialize a Git repository or infer a replacement asset.
|
||||||
|
|
||||||
|
## Creation and replay contract
|
||||||
|
|
||||||
|
`POST /api/workspaces` accepts `operation_key`, `display_name`, and an optional
|
||||||
|
`repository` intent. Omitted and explicit `null` both mean no initial Repository.
|
||||||
|
An object is validated normally: a partial object, invalid URI, or invalid key is
|
||||||
|
not treated as omission. Ownership comes from the authenticated request actor,
|
||||||
|
not a caller-supplied owner field.
|
||||||
|
|
||||||
|
The bootstrap transaction persists the Workspace, optional Repository, initial
|
||||||
|
config, memory settings, Ticket/Objective/Worker resource-key counters, active
|
||||||
|
signing identity, and create receipt together. Signing-material provisioning
|
||||||
|
retains its existing durable reservation/recovery protocol; a failed bootstrap
|
||||||
|
leaves no partially created Workspace or Repository.
|
||||||
|
|
||||||
|
The fingerprint includes normalized creation intent and owner identity. No
|
||||||
|
Repository differs from any Repository intent; repository-present fingerprints
|
||||||
|
keep the existing encoding so saved create receipts remain replayable. The same
|
||||||
|
operation key and input converge, including concurrent calls; changed input is a
|
||||||
|
conflict. No schema migration is needed for the existing fingerprint/Workspace
|
||||||
|
receipts.
|
||||||
|
|
||||||
|
A replay is not a historical snapshot. It returns the current Workspace and
|
||||||
|
config revision. If creation supplied a Repository, it returns the current
|
||||||
|
record **at the originally requested key**. Removal or renaming away from that
|
||||||
|
key produces `repository: null`; registration at that key later returns that
|
||||||
|
current record. Replay never re-registers or resets an asset. If creation omitted
|
||||||
|
a Repository, replay always returns `null`, even after other assets are added.
|
||||||
|
|
||||||
|
## Entry points and zero-Repository use
|
||||||
|
|
||||||
|
- Web creation requires only a display name. Optional Repository fields are
|
||||||
|
disclosed explicitly; Settings retains the later-registration entry point.
|
||||||
|
- `yoi [--backend URL] workspace create --display-name NAME` creates without Git
|
||||||
|
discovery. Select the returned identity with `yoi --workspace-id ID`.
|
||||||
|
- `yoi init` remains Git-oriented and still requires an explicit Repository key.
|
||||||
|
- The TUI creation picker allows Enter at the optional URI prompt to skip Git
|
||||||
|
registration. Workspace selection and ordinary embedded Worker startup do not
|
||||||
|
require a Repository or Workdir. Operations that use Git/Workdirs retain their
|
||||||
|
own prerequisites.
|
||||||
|
- Empty Repository lists are valid, without a missing-configuration warning.
|
||||||
|
Reopening and deletion use the usual Workspace authority and lifecycle guards;
|
||||||
|
in particular, the last-accessible-Workspace deletion guard is unchanged.
|
||||||
|
|
||||||
|
Optional creation preserves the existing owner/member responsibility split:
|
||||||
|
settings are owner-controlled, while ordinary viewing and use are member-facing.
|
||||||
|
Non-ownership alone is not a reason to deny ordinary viewing. Existing
|
||||||
|
member-facing non-owner reads, owner-gated settings mutations, owner-scoped
|
||||||
|
catalog discovery, authentication and operation-permission checks are unchanged.
|
||||||
|
This creation change does not introduce a membership authority or an owner-only
|
||||||
|
viewing filter.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# SubWorker cwd changes logical tool path interpretation
|
||||||
|
|
||||||
|
During T-716 delegation, a child spawned with `cwd: crates/merge-request` and explicit read grants for `docs/development/rust-testing-strategy.md` could not read that path. `Read` was presented as Workdir-root-relative, but the observed error resolved it as `crates/merge-request/docs/development/rust-testing-strategy.md`. The same prefixing occurred for `crates/merge-request/src/lib.rs`; parent traversal was rejected.
|
||||||
|
|
||||||
|
The child made no edits. Rebinding a child at the checkout root with read-only `.` plus writable `crates/merge-request` let it read the required strategy and edit only the delegated crate. No denied write was bypassed.
|
||||||
|
|
||||||
|
Suggested improvement: make logical path resolution and child cwd semantics consistent in the tool description, capability checks, and execution. If cwd affects filesystem tools, expose the effective logical root explicitly and retain an unambiguous way to address other granted read paths. Scope validation should reject unusable grants at spawn rather than leave the child discovering path-prefix behavior.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# T-718: SubWorker の cwd・参照コンテキストに関する障壁
|
||||||
|
|
||||||
|
## 観測
|
||||||
|
|
||||||
|
T-718 の実装を `crates/ticket` / `crates/worker` / `crates/merge-request` に分担した。SubWorkerSpawn の cwd を各 crate に設定すると、子のファイル tool は `src/lib.rs` 等で成功する一方、親で使える repository-root-relative な `crates/<crate>/src/...` は crate prefix が二重になって失敗した。cwd 未指定の Glob は一部の子で transient bash-output 側のパスを logical path として扱い失敗した。
|
||||||
|
|
||||||
|
また builtin default の子には ShowTicket が公開されず、別途 read scope に含めたテスト規則の親 root-relative path も子の cwd から参照できなかった。親が authoritative Ticket とテスト規則を先に読み、要件・規則・インターフェースを本文で転送して作業を継続した。
|
||||||
|
|
||||||
|
## 改善案
|
||||||
|
|
||||||
|
- 子の startup context に実際の Workdir attachment alias・logical root・tool cwd を明示する。
|
||||||
|
- cwd と path root の関係を tool schema / Spawn 説明と一致させ、cwd 外に委譲した read-only scope の参照方法も示す。
|
||||||
|
- Ticket implementation の子へ typed Ticket read を委譲できない場合は、親に authoritative item revision と本文を渡す責任があることを明示する。
|
||||||
|
|
||||||
|
本件では委譲 scope を広げたり Backend storage を直接操作する回避はしていない。製品要件の追加ではなく、分担時の tooling/context で発生した調整コストの記録である。
|
||||||
+511
-35
@@ -301,11 +301,23 @@
|
|||||||
"BrowserCloseTicketRequest": {
|
"BrowserCloseTicketRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"expected_item_revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_state": {
|
||||||
|
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"operation_key",
|
||||||
|
"expected_item_revision",
|
||||||
|
"expected_state",
|
||||||
"resolution"
|
"resolution"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -459,30 +471,34 @@
|
|||||||
"BrowserTransitionTicketStateRequest": {
|
"BrowserTransitionTicketStateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"author": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"body": {
|
"body": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"expected_item_revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_state": {
|
||||||
|
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"type": [
|
"type": "string"
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
|
"$ref": "#/components/schemas/BrowserTicketWorkflowState"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"state"
|
"state",
|
||||||
|
"operation_key",
|
||||||
|
"expected_item_revision",
|
||||||
|
"expected_state",
|
||||||
|
"reason"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1075,6 +1091,37 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"CompleteTicketRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expected_item_revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_state": {
|
||||||
|
"$ref": "#/components/schemas/TicketWorkflowState"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketReference"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"operation_key",
|
||||||
|
"expected_item_revision",
|
||||||
|
"expected_state",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"CompletionContext": {
|
"CompletionContext": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"argument": {
|
"argument": {
|
||||||
@@ -10199,6 +10246,45 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TicketCompletion": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Audited completion independent of repositories, Git, or Merge Requests.",
|
||||||
|
"properties": {
|
||||||
|
"author": {
|
||||||
|
"default": null,
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expected_item_revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_state": {
|
||||||
|
"$ref": "#/components/schemas/TicketWorkflowState"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"default": [],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/TicketReference"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"expected_item_revision",
|
||||||
|
"expected_state",
|
||||||
|
"operation_key",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TicketDependencyCheck": {
|
"TicketDependencyCheck": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"blockers": {
|
"blockers": {
|
||||||
@@ -11221,6 +11307,12 @@
|
|||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"integration_evidence_error": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
"merge_request_id": {
|
"merge_request_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -11263,6 +11355,9 @@
|
|||||||
"selector_to": {
|
"selector_to": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"source_ref_observation": {
|
||||||
|
"$ref": "#/components/schemas/TicketSourceRefObservation"
|
||||||
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -11276,7 +11371,8 @@
|
|||||||
"state",
|
"state",
|
||||||
"review_status",
|
"review_status",
|
||||||
"selector_to",
|
"selector_to",
|
||||||
"updated_at"
|
"updated_at",
|
||||||
|
"source_ref_observation"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -12069,39 +12165,93 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"TicketStateChange": {
|
"TicketSourceRefObservation": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"const": "observed",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"const": "unavailable",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"const": "not_required",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TicketStateUpdate": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "External state mutation with item/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"author": {
|
"author": {
|
||||||
|
"default": null,
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"body": {
|
"expected_item_revision": {
|
||||||
"$ref": "#/components/schemas/MarkdownText"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"from": {
|
"expected_state": {
|
||||||
|
"$ref": "#/components/schemas/TicketWorkflowState"
|
||||||
|
},
|
||||||
|
"operation_key": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"references": {
|
"references": {
|
||||||
|
"default": [],
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TicketReference"
|
"$ref": "#/components/schemas/TicketReference"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
"to": {
|
"state": {
|
||||||
"type": "string"
|
"$ref": "#/components/schemas/TicketWorkflowState"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"from",
|
"expected_item_revision",
|
||||||
"to",
|
"expected_state",
|
||||||
"reason",
|
"state",
|
||||||
"body",
|
"operation_key",
|
||||||
"references"
|
"reason"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -13906,7 +14056,7 @@
|
|||||||
},
|
},
|
||||||
"WorkspaceCreateRequest": {
|
"WorkspaceCreateRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Request for atomically creating a Workspace and its initial Repository.",
|
"description": "Request for atomically creating a Workspace with an optional initial Repository.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"display_name": {
|
"display_name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -13915,19 +14065,25 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"$ref": "#/components/schemas/InitialRepositoryIntent"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/InitialRepositoryIntent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"operation_key",
|
"operation_key",
|
||||||
"display_name",
|
"display_name"
|
||||||
"repository"
|
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"WorkspaceCreateResponse": {
|
"WorkspaceCreateResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Response returned after atomically creating a Workspace and its first Repository.",
|
"description": "Response returned after atomically creating a Workspace.\n\nOn replay, Workspace/config and the Repository at the originally requested key are\ncurrent authority, not historical snapshots. Repository is null when creation omitted\nit or that key has since been removed/renamed; replay never recreates an asset.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"config_revision": {
|
"config_revision": {
|
||||||
"format": "uint64",
|
"format": "uint64",
|
||||||
@@ -13939,7 +14095,14 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"$ref": "#/components/schemas/WorkspaceRepositoryRecord"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/WorkspaceRepositoryRecord"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"request_fingerprint": {
|
"request_fingerprint": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -13950,7 +14113,6 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"workspace",
|
"workspace",
|
||||||
"repository",
|
|
||||||
"config_revision",
|
"config_revision",
|
||||||
"request_fingerprint",
|
"request_fingerprint",
|
||||||
"replayed"
|
"replayed"
|
||||||
@@ -15481,7 +15643,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf"
|
"x-yoi-source-digest": "sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -31941,6 +32103,143 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/complete": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "ticket_complete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CompleteTicketRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Ticket"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -33500,6 +33799,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -33550,7 +33859,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/TicketStateChange"
|
"$ref": "#/components/schemas/TicketStateUpdate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33610,6 +33919,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -33670,7 +33989,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/TicketStateChange"
|
"$ref": "#/components/schemas/TicketStateUpdate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33730,6 +34049,143 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/state-update": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "ticket_state_update",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "workspace_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"explode": false,
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/string"
|
||||||
|
},
|
||||||
|
"style": "simple"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TicketStateUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Ticket"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -33890,7 +34346,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/TicketStateChange"
|
"$ref": "#/components/schemas/TicketStateUpdate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33950,6 +34406,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -34000,7 +34466,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MarkdownText"
|
"$ref": "#/components/schemas/TicketCompletion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -34060,6 +34526,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Error response"
|
"description": "Error response"
|
||||||
},
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ Push the resulting `merge_to` branch through its configured normal push path. Pr
|
|||||||
|
|
||||||
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated.
|
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or integrated.
|
||||||
|
|
||||||
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
|
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases unfinished work atomically while retaining current responsibility for display; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint.
|
||||||
|
|
||||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||||
|
|
||||||
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
||||||
|
|
||||||
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization.
|
||||||
|
|
||||||
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no unfinished Ticket work (terminal current responsibility may remain), then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied.
|
||||||
|
|
||||||
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||||
|
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||||
"build": "deno run -A npm:vite@8.3.2 build",
|
"build": "deno run -A npm:vite@8.3.2 build",
|
||||||
"preview": "deno run -A npm:vite@8.3.2 preview",
|
"preview": "deno run -A npm:vite@8.3.2 preview",
|
||||||
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type WorkspaceRepositoryRecord = {
|
|||||||
|
|
||||||
export type WorkspaceCreateResponse = {
|
export type WorkspaceCreateResponse = {
|
||||||
workspace: WorkspaceSummary;
|
workspace: WorkspaceSummary;
|
||||||
repository: WorkspaceRepositoryRecord;
|
repository: WorkspaceRepositoryRecord | null;
|
||||||
config_revision: number;
|
config_revision: number;
|
||||||
request_fingerprint: string;
|
request_fingerprint: string;
|
||||||
replayed: boolean;
|
replayed: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|
||||||
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
|
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
|
||||||
|
|
||||||
export type BrowserCloseTicketRequest = { resolution: string; };
|
export type BrowserCloseTicketRequest = { expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
|
||||||
|
|
||||||
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
|
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "impleme
|
|||||||
|
|
||||||
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
|
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
|
||||||
|
|
||||||
export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; };
|
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
|
||||||
|
|
||||||
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
|
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>;
|
|||||||
|
|
||||||
export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; };
|
export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; };
|
||||||
|
|
||||||
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; };
|
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
|
||||||
|
|
||||||
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
|
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
|
||||||
|
|
||||||
@@ -131,6 +131,8 @@ export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: str
|
|||||||
|
|
||||||
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
|
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
|
||||||
|
|
||||||
|
export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; };
|
||||||
|
|
||||||
export type TicketStatus = "Open" | "Closed";
|
export type TicketStatus = "Open" | "Closed";
|
||||||
|
|
||||||
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
|
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf)
|
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
parseMergeRequestDetailResponse,
|
parseMergeRequestDetailResponse,
|
||||||
|
parseMergeRequestListResponse,
|
||||||
parseObjectiveListResponse,
|
parseObjectiveListResponse,
|
||||||
parseTicketDetail,
|
parseTicketDetail,
|
||||||
parseTicketListResponse,
|
parseTicketListResponse,
|
||||||
@@ -80,8 +81,8 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
|
function ticketDetailFixture() {
|
||||||
const fixture = {
|
return {
|
||||||
action_eligibility: {
|
action_eligibility: {
|
||||||
blockers: [],
|
blockers: [],
|
||||||
can_assign_orchestrator: false,
|
can_assign_orchestrator: false,
|
||||||
@@ -142,7 +143,10 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular
|
|||||||
],
|
],
|
||||||
title: "Ticket",
|
title: "Ticket",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
|
||||||
|
const fixture = ticketDetailFixture();
|
||||||
const parsed = parseTicketDetail(fixture);
|
const parsed = parseTicketDetail(fixture);
|
||||||
assertEquals(parsed.targets.length, 2);
|
assertEquals(parsed.targets.length, 2);
|
||||||
assertEquals(parsed.targets[0]?.repository_key, "docs");
|
assertEquals(parsed.targets[0]?.repository_key, "docs");
|
||||||
@@ -168,6 +172,157 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mergeRequestSummary = {
|
||||||
|
merge_request_id: "mr-1",
|
||||||
|
repository_key: "main",
|
||||||
|
state: "open",
|
||||||
|
selector_from: "work/T-1",
|
||||||
|
selector_to: "develop",
|
||||||
|
updated_at: "2026-10-08T00:00:00Z",
|
||||||
|
current_subject_ref: "source-1",
|
||||||
|
source_ref_observation: { status: "observed" },
|
||||||
|
integration_evidence_error: null,
|
||||||
|
review_status: "approved",
|
||||||
|
review_subject_ref: "source-1",
|
||||||
|
review_requested_at: null,
|
||||||
|
review_submitted_at: null,
|
||||||
|
review_excerpt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeRequestListFixture(summary: unknown) {
|
||||||
|
return {
|
||||||
|
items: [{ summary, ticket_ids: ["ticket-id"], thread_event_count: 2 }],
|
||||||
|
next_cursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Merge Request summaries preserve live observation and immutable merged evidence separately", () => {
|
||||||
|
for (
|
||||||
|
const summary of [
|
||||||
|
mergeRequestSummary,
|
||||||
|
{
|
||||||
|
...mergeRequestSummary,
|
||||||
|
current_subject_ref: null,
|
||||||
|
source_ref_observation: {
|
||||||
|
status: "unavailable",
|
||||||
|
code: "repository_unavailable",
|
||||||
|
},
|
||||||
|
review_status: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mergeRequestSummary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mergeRequestSummary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
integration_evidence_error: "approval_event_missing",
|
||||||
|
review_status: "none",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const listed = parseMergeRequestListResponse(
|
||||||
|
mergeRequestListFixture(summary),
|
||||||
|
);
|
||||||
|
const detailed = parseTicketDetail({
|
||||||
|
...ticketDetailFixture(),
|
||||||
|
merge_requests: [summary],
|
||||||
|
merge_request: summary,
|
||||||
|
});
|
||||||
|
for (
|
||||||
|
const parsed of [
|
||||||
|
listed.items[0]?.summary,
|
||||||
|
detailed.merge_requests[0],
|
||||||
|
detailed.merge_request,
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertEquals(
|
||||||
|
parsed?.source_ref_observation,
|
||||||
|
summary.source_ref_observation,
|
||||||
|
);
|
||||||
|
assertEquals(parsed?.current_subject_ref, summary.current_subject_ref);
|
||||||
|
assertEquals(
|
||||||
|
parsed?.integration_evidence_error,
|
||||||
|
summary.integration_evidence_error,
|
||||||
|
);
|
||||||
|
assertEquals(parsed?.review_subject_ref, summary.review_subject_ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Merge Request summaries reject missing and malformed tagged source observations", () => {
|
||||||
|
for (
|
||||||
|
const [observation, message] of [
|
||||||
|
[undefined, "source_ref_observation must be an object"],
|
||||||
|
[null, "source_ref_observation must be an object"],
|
||||||
|
["observed", "source_ref_observation must be an object"],
|
||||||
|
[{ status: "unknown" }, "status is invalid"],
|
||||||
|
[{ status: "unavailable" }, "code must be a string"],
|
||||||
|
[{ status: "unavailable", code: 42 }, "code must be a string"],
|
||||||
|
[
|
||||||
|
{ status: "unavailable", code: "x".repeat(262_145) },
|
||||||
|
"code exceeds its length limit",
|
||||||
|
],
|
||||||
|
[{ status: "observed", code: "stale" }, "unknown field code"],
|
||||||
|
[{ status: "not_required", code: "stale" }, "unknown field code"],
|
||||||
|
[
|
||||||
|
{ status: "unavailable", code: "missing", extra: true },
|
||||||
|
"unknown field extra",
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
) {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseMergeRequestListResponse(mergeRequestListFixture({
|
||||||
|
...mergeRequestSummary,
|
||||||
|
source_ref_observation: observation,
|
||||||
|
})),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Merge Request summaries require a nullable bounded integration evidence error", () => {
|
||||||
|
for (
|
||||||
|
const [error, message] of [
|
||||||
|
[undefined, "integration_evidence_error must be a string"],
|
||||||
|
[false, "integration_evidence_error must be a string"],
|
||||||
|
[
|
||||||
|
"x".repeat(262_145),
|
||||||
|
"integration_evidence_error exceeds its length limit",
|
||||||
|
],
|
||||||
|
] as const
|
||||||
|
) {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseMergeRequestListResponse(mergeRequestListFixture({
|
||||||
|
...mergeRequestSummary,
|
||||||
|
integration_evidence_error: error,
|
||||||
|
})),
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||||
|
const fixture = ticketDetailFixture();
|
||||||
|
const parsed = parseTicketDetail({
|
||||||
|
...fixture,
|
||||||
|
state: "done",
|
||||||
|
evidence: { ...fixture.evidence, missing: ["review_after_rescope"] },
|
||||||
|
merge_requests: [{
|
||||||
|
...mergeRequestSummary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assertEquals(parsed.state, "done");
|
||||||
|
assertEquals(parsed.evidence.complete_for_integration, false);
|
||||||
|
assertEquals(parsed.merge_requests[0]?.review_status, "approved");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Ticket Browser parser validates Ticket create responses", () => {
|
Deno.test("Ticket Browser parser validates Ticket create responses", () => {
|
||||||
const parsed = parseTicketRecordRef({
|
const parsed = parseTicketRecordRef({
|
||||||
id: "ticket-id",
|
id: "ticket-id",
|
||||||
|
|||||||
@@ -446,6 +446,23 @@ function parseActionEligibility(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSourceRefObservation(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): TicketMergeRequestSummary["source_ref_observation"] {
|
||||||
|
const item = object(value, label);
|
||||||
|
const status = string(item.status, `${label}.status`);
|
||||||
|
if (status === "observed" || status === "not_required") {
|
||||||
|
exact(item, ["status"], label);
|
||||||
|
return { status };
|
||||||
|
}
|
||||||
|
if (status === "unavailable") {
|
||||||
|
exact(item, ["status", "code"], label);
|
||||||
|
return { status, code: string(item.code, `${label}.code`) };
|
||||||
|
}
|
||||||
|
throw new Error(`${label}.status is invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
function parseTicketMergeRequestSummary(
|
function parseTicketMergeRequestSummary(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -453,6 +470,8 @@ function parseTicketMergeRequestSummary(
|
|||||||
const item = object(value, label);
|
const item = object(value, label);
|
||||||
exact(item, [
|
exact(item, [
|
||||||
"current_subject_ref",
|
"current_subject_ref",
|
||||||
|
"integration_evidence_error",
|
||||||
|
"source_ref_observation",
|
||||||
"merge_request_id",
|
"merge_request_id",
|
||||||
"repository_key",
|
"repository_key",
|
||||||
"review_excerpt",
|
"review_excerpt",
|
||||||
@@ -470,6 +489,14 @@ function parseTicketMergeRequestSummary(
|
|||||||
item.current_subject_ref,
|
item.current_subject_ref,
|
||||||
`${label}.current_subject_ref`,
|
`${label}.current_subject_ref`,
|
||||||
),
|
),
|
||||||
|
integration_evidence_error: nullableString(
|
||||||
|
item.integration_evidence_error,
|
||||||
|
`${label}.integration_evidence_error`,
|
||||||
|
),
|
||||||
|
source_ref_observation: parseSourceRefObservation(
|
||||||
|
item.source_ref_observation,
|
||||||
|
`${label}.source_ref_observation`,
|
||||||
|
),
|
||||||
merge_request_id: string(
|
merge_request_id: string(
|
||||||
item.merge_request_id,
|
item.merge_request_id,
|
||||||
`${label}.merge_request_id`,
|
`${label}.merge_request_id`,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const createdWorkspace = {
|
||||||
|
workspace_id: "home-empty",
|
||||||
|
owner_account_id: "fixture-owner",
|
||||||
|
display_name: "New Workspace",
|
||||||
|
state: "active",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const currentInitialRepository = {
|
||||||
|
workspace_id: createdWorkspace.workspace_id,
|
||||||
|
repository_key: "platform",
|
||||||
|
kind: "git",
|
||||||
|
provider: "git",
|
||||||
|
source: { kind: "https", uri: "https://example.test/current.git" },
|
||||||
|
default_ref: "main",
|
||||||
|
source_revision: 2,
|
||||||
|
source_fingerprint: "sha256:current",
|
||||||
|
observed_status: "unverified",
|
||||||
|
observed_at: null,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function creationResponse(repository: unknown = null, replayed = false) {
|
||||||
|
return {
|
||||||
|
workspace: createdWorkspace,
|
||||||
|
repository,
|
||||||
|
config_revision: 1,
|
||||||
|
request_fingerprint: "sha256:creation",
|
||||||
|
replayed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,11 +17,11 @@ export type CreateWorkspaceResponse = WorkspaceCreateResponse;
|
|||||||
export type CreateWorkspaceRequest = {
|
export type CreateWorkspaceRequest = {
|
||||||
operation_key: string;
|
operation_key: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
repository: {
|
repository?: {
|
||||||
repository_key: string;
|
repository_key: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
default_ref: string | null;
|
default_ref: string | null;
|
||||||
};
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class WorkspaceCatalogError extends Error {
|
export class WorkspaceCatalogError extends Error {
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ export function parseWorkspaceCreateResponse(
|
|||||||
response.workspace,
|
response.workspace,
|
||||||
"workspace create response.workspace",
|
"workspace create response.workspace",
|
||||||
),
|
),
|
||||||
repository: workspaceRepositoryRecord(
|
repository: response.repository === null ? null : workspaceRepositoryRecord(
|
||||||
response.repository,
|
response.repository,
|
||||||
"workspace create response.repository",
|
"workspace create response.repository",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ export function dashboardFixture(
|
|||||||
review_requested_at: updated(10),
|
review_requested_at: updated(10),
|
||||||
review_submitted_at: null,
|
review_submitted_at: null,
|
||||||
current_subject_ref: "abc123",
|
current_subject_ref: "abc123",
|
||||||
|
source_ref_observation: { status: "observed" },
|
||||||
|
integration_evidence_error: null,
|
||||||
review_subject_ref: "abc123",
|
review_subject_ref: "abc123",
|
||||||
},
|
},
|
||||||
thread_event_count: 1,
|
thread_event_count: 1,
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
readTextFile(path: URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
import type { TicketMergeRequestSummary } from "#lib/generated/ticket-api.ts";
|
||||||
|
import { fixtureDetail } from "./home/dashboard.test-fixtures.ts";
|
||||||
|
import { parseTicketDetail } from "./api/ticket-browser.ts";
|
||||||
|
import {
|
||||||
|
currentRequirementApprovalStatus,
|
||||||
|
sourceReviewFreshness,
|
||||||
|
summarySourceReviewStatus,
|
||||||
|
} from "./merge-request-status.ts";
|
||||||
|
import type { MergeRequestDetail } from "./api/merge-requests.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected ${JSON.stringify(expected)}, received ${
|
||||||
|
JSON.stringify(actual)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: TicketMergeRequestSummary = {
|
||||||
|
merge_request_id: "mr-1",
|
||||||
|
repository_key: "main",
|
||||||
|
state: "open",
|
||||||
|
selector_from: "work/T-1",
|
||||||
|
selector_to: "develop",
|
||||||
|
updated_at: "2026-10-08T00:00:00Z",
|
||||||
|
current_subject_ref: "source-1",
|
||||||
|
source_ref_observation: { status: "observed" },
|
||||||
|
integration_evidence_error: null,
|
||||||
|
review_status: "approved",
|
||||||
|
review_subject_ref: "source-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("unavailable live observation shows its error rather than claiming review is stale or pending", () => {
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus({
|
||||||
|
...summary,
|
||||||
|
current_subject_ref: null,
|
||||||
|
source_ref_observation: {
|
||||||
|
status: "unavailable",
|
||||||
|
code: "repository_unavailable",
|
||||||
|
},
|
||||||
|
review_status: "pending",
|
||||||
|
}),
|
||||||
|
"Live source observation unavailable: repository_unavailable.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("merged summary labels approval as immutable persisted evidence without live observation", () => {
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus({
|
||||||
|
...summary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
}),
|
||||||
|
"Integration approval recorded for immutable merged source source-1.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("merged evidence error does not claim a valid integration approval", () => {
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus({
|
||||||
|
...summary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
integration_evidence_error: "approval_event_missing",
|
||||||
|
}),
|
||||||
|
"Integration approval unavailable for immutable merged source source-1.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("observed live source movement requires fresh review but an unchanged ref retains approval", () => {
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus(summary),
|
||||||
|
"Live source review: approved for exact ref source-1.",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus({ ...summary, current_subject_ref: "source-2" }),
|
||||||
|
"Fresh live source review required: selector_from moved from source-1 to source-2.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||||
|
const ticket = parseTicketDetail({
|
||||||
|
...fixtureDetail(),
|
||||||
|
state: "done",
|
||||||
|
evidence: {
|
||||||
|
...fixtureDetail().evidence,
|
||||||
|
has_merge_request: true,
|
||||||
|
has_current_subject_ref: true,
|
||||||
|
has_commit: true,
|
||||||
|
has_review_request: true,
|
||||||
|
review_status: "pending",
|
||||||
|
approved_current_subject: true,
|
||||||
|
review_after_rescope: false,
|
||||||
|
missing: ["review_after_rescope"],
|
||||||
|
},
|
||||||
|
merge_requests: [{
|
||||||
|
...summary,
|
||||||
|
state: "merged",
|
||||||
|
source_ref_observation: { status: "not_required" },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assertEquals(ticket.state, "done");
|
||||||
|
assertEquals(ticket.evidence.complete_for_integration, false);
|
||||||
|
assertEquals(
|
||||||
|
currentRequirementApprovalStatus(ticket.evidence),
|
||||||
|
"not established",
|
||||||
|
);
|
||||||
|
const recovered = parseTicketDetail({
|
||||||
|
...ticket,
|
||||||
|
evidence: {
|
||||||
|
...ticket.evidence,
|
||||||
|
review_after_rescope: true,
|
||||||
|
review_status: "approved",
|
||||||
|
complete_for_integration: true,
|
||||||
|
missing: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals(recovered.evidence.approved_current_subject, true);
|
||||||
|
assertEquals(
|
||||||
|
currentRequirementApprovalStatus(recovered.evidence),
|
||||||
|
"approved",
|
||||||
|
);
|
||||||
|
assertEquals(recovered.state, "done");
|
||||||
|
assertEquals(
|
||||||
|
summarySourceReviewStatus(ticket.merge_requests[0]!),
|
||||||
|
"Integration approval recorded for immutable merged source source-1.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => {
|
||||||
|
const source = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const required of [
|
||||||
|
"Current requirement evidence",
|
||||||
|
"ticket.evidence.complete_for_integration",
|
||||||
|
"Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}",
|
||||||
|
"ticket.evidence.missing",
|
||||||
|
"summarySourceReviewStatus(mergeRequest)",
|
||||||
|
"Immutable integration evidence",
|
||||||
|
"Integration evidence error:",
|
||||||
|
'{#if ticket.state === "done"}',
|
||||||
|
"Incomplete current requirement evidence does not cancel recorded completion.",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
if (!source.includes(required)) {
|
||||||
|
throw new Error(`Ticket route must include ${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => {
|
||||||
|
const list = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../routes/w/[workspaceId]/merge-requests/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assertEquals(list.includes("summarySourceReviewStatus(mergeRequest)"), true);
|
||||||
|
assertEquals(list.includes("Integration evidence error:"), true);
|
||||||
|
const detail = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assertEquals(detail.includes("Recorded integration approval"), true);
|
||||||
|
assertEquals(detail.includes("Live source revision"), true);
|
||||||
|
assertEquals(detail.includes("Source observation error"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => {
|
||||||
|
const detail: MergeRequestDetail = {
|
||||||
|
merge_request_id: "mr-1",
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
repository_key: "main",
|
||||||
|
ticket_ids: [],
|
||||||
|
linked_tickets: [],
|
||||||
|
selector_from: "work/T-1",
|
||||||
|
selector_to: "develop",
|
||||||
|
state: "merged",
|
||||||
|
created_at: summary.updated_at,
|
||||||
|
updated_at: summary.updated_at,
|
||||||
|
source: {
|
||||||
|
status: "known",
|
||||||
|
ref: "source-1",
|
||||||
|
observed_at: summary.updated_at,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
status: "known",
|
||||||
|
ref: "target-2",
|
||||||
|
observed_at: summary.updated_at,
|
||||||
|
},
|
||||||
|
thread: [{
|
||||||
|
kind: "merge",
|
||||||
|
event_id: "merge-1",
|
||||||
|
sequence: 3,
|
||||||
|
created_at: summary.updated_at,
|
||||||
|
operation_id: "operation-1",
|
||||||
|
approval_event_id: "approval-1",
|
||||||
|
approved_source_ref: "source-1",
|
||||||
|
target_ref_before: "target-1",
|
||||||
|
target_ref_after: "target-2",
|
||||||
|
strategy: "fast_forward",
|
||||||
|
resolution: "none",
|
||||||
|
merged_by: { runtime_id: "runtime-a", worker_id: "worker-a" },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assertEquals(
|
||||||
|
sourceReviewFreshness(detail),
|
||||||
|
"Recorded integration approval approval-1 for immutable merged source source-1.",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
sourceReviewFreshness({ ...detail, thread: [] }),
|
||||||
|
"Recorded integration approval for immutable merged source source-1.",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
sourceReviewFreshness({
|
||||||
|
...detail,
|
||||||
|
source: {
|
||||||
|
...detail.source,
|
||||||
|
status: "unknown",
|
||||||
|
diagnostic: { code: "approval_revoked", message: "Revoked" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Immutable integration evidence unavailable: approval_revoked.",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
sourceReviewFreshness({
|
||||||
|
...detail,
|
||||||
|
source: { ...detail.source, status: "unknown", ref: null },
|
||||||
|
}),
|
||||||
|
"Immutable integration evidence unavailable: no validated persisted source.",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import type {
|
||||||
|
TicketEvidenceSummary,
|
||||||
|
TicketMergeRequestSummary,
|
||||||
|
} from "#lib/generated/ticket-api.ts";
|
||||||
import type {
|
import type {
|
||||||
MergeRequestDetail,
|
MergeRequestDetail,
|
||||||
MergeRequestThreadEvent,
|
MergeRequestThreadEvent,
|
||||||
@@ -29,6 +33,24 @@ function requestHasTerminalOutcome(
|
|||||||
export function sourceReviewFreshness(
|
export function sourceReviewFreshness(
|
||||||
mergeRequest: MergeRequestDetail,
|
mergeRequest: MergeRequestDetail,
|
||||||
): string {
|
): string {
|
||||||
|
if (mergeRequest.state === "merged") {
|
||||||
|
// The Backend validates the full persisted result before paging the thread.
|
||||||
|
// A merge event in a page alone is not evidence of a valid approval.
|
||||||
|
if (mergeRequest.source.diagnostic) {
|
||||||
|
return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`;
|
||||||
|
}
|
||||||
|
const source = mergeRequest.source.ref;
|
||||||
|
if (mergeRequest.source.status !== "known" || !source) {
|
||||||
|
return "Immutable integration evidence unavailable: no validated persisted source.";
|
||||||
|
}
|
||||||
|
const merge = [...mergeRequest.thread].reverse().find(
|
||||||
|
(event): event is Extract<MergeRequestThreadEvent, { kind: "merge" }> =>
|
||||||
|
event.kind === "merge",
|
||||||
|
);
|
||||||
|
return merge
|
||||||
|
? `Recorded integration approval ${merge.approval_event_id} for immutable merged source ${source}.`
|
||||||
|
: `Recorded integration approval for immutable merged source ${source}.`;
|
||||||
|
}
|
||||||
const source = mergeRequest.source.ref;
|
const source = mergeRequest.source.ref;
|
||||||
if (!source) return "Source review unavailable: selector_from is unresolved.";
|
if (!source) return "Source review unavailable: selector_from is unresolved.";
|
||||||
|
|
||||||
@@ -70,6 +92,49 @@ export function sourceReviewFreshness(
|
|||||||
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
return `Fresh source review required: no effective verdict exists for ${source}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source/integration approval can survive rescope; only this exact revision
|
||||||
|
// and linked-result attestation approves the current Ticket requirements.
|
||||||
|
export function currentRequirementApprovalStatus(
|
||||||
|
evidence: TicketEvidenceSummary,
|
||||||
|
): string {
|
||||||
|
return evidence.review_after_rescope ? "approved" : "not established";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarySourceReviewStatus(
|
||||||
|
mergeRequest: TicketMergeRequestSummary,
|
||||||
|
): string {
|
||||||
|
const source = mergeRequest.current_subject_ref;
|
||||||
|
if (mergeRequest.state === "merged") {
|
||||||
|
if (!source) return "Immutable merged source unavailable.";
|
||||||
|
if (
|
||||||
|
mergeRequest.integration_evidence_error !== null ||
|
||||||
|
mergeRequest.review_status !== "approved" ||
|
||||||
|
mergeRequest.review_subject_ref !== source
|
||||||
|
) {
|
||||||
|
return `Integration approval unavailable for immutable merged source ${source}.`;
|
||||||
|
}
|
||||||
|
return `Integration approval recorded for immutable merged source ${source}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observation = mergeRequest.source_ref_observation;
|
||||||
|
if (observation.status === "unavailable") {
|
||||||
|
return `Live source observation unavailable: ${observation.code}.`;
|
||||||
|
}
|
||||||
|
if (observation.status === "not_required") {
|
||||||
|
return "Live source observation not required.";
|
||||||
|
}
|
||||||
|
if (!source) {
|
||||||
|
return "Live source review unavailable: no source ref was observed.";
|
||||||
|
}
|
||||||
|
if (mergeRequest.review_subject_ref === source) {
|
||||||
|
return `Live source review: ${mergeRequest.review_status} for exact ref ${source}.`;
|
||||||
|
}
|
||||||
|
if (mergeRequest.review_subject_ref) {
|
||||||
|
return `Fresh live source review required: selector_from moved from ${mergeRequest.review_subject_ref} to ${source}.`;
|
||||||
|
}
|
||||||
|
return `Fresh live source review required: no effective verdict exists for ${source}.`;
|
||||||
|
}
|
||||||
|
|
||||||
export function targetIntegrationStatus(
|
export function targetIntegrationStatus(
|
||||||
mergeRequest: MergeRequestDetail,
|
mergeRequest: MergeRequestDetail,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { WorkerLaunchOptionsResponse } from "./types.ts";
|
||||||
|
|
||||||
|
export function emptyLaunchOptions(
|
||||||
|
workspaceId: string,
|
||||||
|
): WorkerLaunchOptionsResponse {
|
||||||
|
return {
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
runtimes: [
|
||||||
|
{
|
||||||
|
runtime_id: "embedded",
|
||||||
|
display_name: "Embedded",
|
||||||
|
status: "active",
|
||||||
|
worker_creation_available: true,
|
||||||
|
built_in: true,
|
||||||
|
working_directory_required: false,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runtime_id: "remote",
|
||||||
|
display_name: "Remote",
|
||||||
|
status: "active",
|
||||||
|
worker_creation_available: true,
|
||||||
|
built_in: false,
|
||||||
|
working_directory_required: true,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default_profile: "builtin:companion",
|
||||||
|
profiles: [{
|
||||||
|
id: "builtin:companion",
|
||||||
|
label: "Companion",
|
||||||
|
description: "Conversation",
|
||||||
|
feature_connections: { subjektiv: false },
|
||||||
|
}],
|
||||||
|
repositories: [],
|
||||||
|
working_directories: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
.workspace-catalog-grid {
|
.workspace-catalog-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
.workspace-create-panel {
|
.workspace-create-panel {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
|
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +136,32 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel .workspace-repository-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-repository-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel input:not([type="checkbox"]) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-create-panel label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-create-row > label {
|
.workspace-create-row > label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,16 @@ Deno.test("Merge Request UI separates source review freshness from target integr
|
|||||||
detailPage.includes("targetIntegrationStatus"),
|
detailPage.includes("targetIntegrationStatus"),
|
||||||
"MR detail page does not render authority status projections",
|
"MR detail page does not render authority status projections",
|
||||||
);
|
);
|
||||||
for (const source of [`${detailPage}\n${statusProjection}`, ticketPage]) {
|
assert(
|
||||||
|
ticketPage.includes("summarySourceReviewStatus(mergeRequest)"),
|
||||||
|
"Ticket page does not render the source observation/integration evidence projection",
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const source of [
|
||||||
|
`${detailPage}\n${statusProjection}`,
|
||||||
|
`${ticketPage}\n${statusProjection}`,
|
||||||
|
]
|
||||||
|
) {
|
||||||
assert(
|
assert(
|
||||||
source.includes("Fresh source review required"),
|
source.includes("Fresh source review required"),
|
||||||
"missing source-review freshness diagnostic",
|
"missing source-review freshness diagnostic",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let creationError = $state<string | null>(null);
|
let creationError = $state<string | null>(null);
|
||||||
let displayName = $state("");
|
let displayName = $state("");
|
||||||
|
let includeRepository = $state(false);
|
||||||
let repositoryKey = $state("");
|
let repositoryKey = $state("");
|
||||||
let repositoryUri = $state("");
|
let repositoryUri = $state("");
|
||||||
let defaultRef = $state("");
|
let defaultRef = $state("");
|
||||||
@@ -46,23 +47,17 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (creating) return;
|
if (creating) return;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
displayName: displayName.trim(),
|
display_name: displayName.trim(),
|
||||||
repositoryUri: repositoryUri.trim(),
|
repository: includeRepository ? {
|
||||||
repositoryKey: repositoryKey.trim(),
|
repository_key: repositoryKey.trim(),
|
||||||
defaultRef: defaultRef.trim(),
|
uri: repositoryUri.trim(),
|
||||||
|
default_ref: defaultRef.trim() || null,
|
||||||
|
} : null,
|
||||||
};
|
};
|
||||||
const signature = JSON.stringify(normalized);
|
const signature = JSON.stringify(normalized);
|
||||||
const request = lastSubmission?.signature === signature
|
const request = lastSubmission?.signature === signature
|
||||||
? lastSubmission.request
|
? lastSubmission.request
|
||||||
: {
|
: { operation_key: createOperationKey(), ...normalized };
|
||||||
operation_key: createOperationKey(),
|
|
||||||
display_name: normalized.displayName,
|
|
||||||
repository: {
|
|
||||||
repository_key: normalized.repositoryKey,
|
|
||||||
uri: normalized.repositoryUri,
|
|
||||||
default_ref: normalized.defaultRef || null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
lastSubmission = { signature, request };
|
lastSubmission = { signature, request };
|
||||||
creating = true;
|
creating = true;
|
||||||
creationError = null;
|
creationError = null;
|
||||||
@@ -154,29 +149,38 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||||
<p>
|
<p>You can add repositories later in Workspace Settings.</p>
|
||||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<form onsubmit={submitCreation}>
|
<form onsubmit={submitCreation}>
|
||||||
<label>
|
<label>
|
||||||
Workspace display name
|
Workspace display name
|
||||||
<input bind:value={displayName} required autocomplete="off" />
|
<input bind:value={displayName} required autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="workspace-repository-toggle">
|
||||||
Initial repository absolute path or URI
|
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
|
||||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
Add an initial repository (optional)
|
||||||
</label>
|
</label>
|
||||||
<div class="workspace-create-row">
|
{#if includeRepository}
|
||||||
<label>
|
<div id="initial-repository-fields" class="workspace-repository-fields">
|
||||||
Repository key
|
<p>
|
||||||
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9]" maxlength="64" autocomplete="off" />
|
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||||
</label>
|
</p>
|
||||||
<label>
|
<label>
|
||||||
Default ref
|
Initial repository absolute path or URI
|
||||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<div class="workspace-create-row">
|
||||||
|
<label>
|
||||||
|
Repository key
|
||||||
|
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Default ref
|
||||||
|
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if creationError}
|
{#if creationError}
|
||||||
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||||
|
import { summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
@@ -40,8 +41,12 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<p class="ticket-summary">
|
<p class="ticket-summary">
|
||||||
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}
|
Repository {mergeRequest.repository_key} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="ticket-summary">{summarySourceReviewStatus(mergeRequest)}</p>
|
||||||
|
{#if mergeRequest.integration_evidence_error !== null}
|
||||||
|
<p class="workspace-callout is-error">Integration evidence error: {mergeRequest.integration_evidence_error}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-meta">
|
<div class="ticket-meta">
|
||||||
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
|
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
|
||||||
|
|||||||
+5
-2
@@ -59,7 +59,10 @@
|
|||||||
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
|
<div><dt>Repository</dt><dd>{mergeRequest.repository_key}</dd></div>
|
||||||
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
|
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
|
||||||
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
|
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
|
||||||
<div><dt>Source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
<div><dt>Live source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
|
||||||
|
{#if mergeRequest.source.diagnostic}
|
||||||
|
<div><dt>Source observation error</dt><dd>{mergeRequest.source.diagnostic.code}: {mergeRequest.source.diagnostic.message}</dd></div>
|
||||||
|
{/if}
|
||||||
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
|
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
|
||||||
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
|
||||||
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
|
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
|
||||||
@@ -69,7 +72,7 @@
|
|||||||
<section class="ticket-detail-section">
|
<section class="ticket-detail-section">
|
||||||
<div class="ticket-section-heading"><h2>Review and integration status</h2></div>
|
<div class="ticket-section-heading"><h2>Review and integration status</h2></div>
|
||||||
<dl class="ticket-facts">
|
<dl class="ticket-facts">
|
||||||
<div><dt>Source review freshness</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
|
<div><dt>{mergeRequest.state === "merged" ? "Recorded integration approval" : "Live source review freshness"}</dt><dd>{sourceReviewFreshness(mergeRequest)}</dd></div>
|
||||||
<div><dt>Target integration</dt><dd>{targetIntegrationStatus(mergeRequest)}</dd></div>
|
<div><dt>Target integration</dt><dd>{targetIntegrationStatus(mergeRequest)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -383,6 +383,53 @@ test("deletion retry reuses the persisted operation id and confirmation, then re
|
|||||||
.toBeNull();
|
.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("zero-repository Workspace deletion follows authoritative preflight and exact-name confirmation", async () => {
|
||||||
|
const resources = {
|
||||||
|
workers: 0,
|
||||||
|
workdirs: 0,
|
||||||
|
repositories: 0,
|
||||||
|
runtime_bindings: 0,
|
||||||
|
secrets: 0,
|
||||||
|
artifacts: 0,
|
||||||
|
};
|
||||||
|
const api = mockApi(async (request) => {
|
||||||
|
if (request.url.includes("/workspace-deletions/")) {
|
||||||
|
return Response.json({
|
||||||
|
...operation("succeeded", request.url.split("/").pop()),
|
||||||
|
resources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.endsWith("/deletion")) {
|
||||||
|
return request.method === "POST"
|
||||||
|
? Response.json({
|
||||||
|
...operation("succeeded", (await request.json()).operation_id),
|
||||||
|
resources,
|
||||||
|
})
|
||||||
|
: Response.json({
|
||||||
|
...deletionFixture(),
|
||||||
|
resources,
|
||||||
|
can_delete: true,
|
||||||
|
blockers: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
const confirmation = await openDeletion();
|
||||||
|
const remove = screen.getByRole("button", { name: "Delete Workspace" });
|
||||||
|
expect((remove as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await fireEvent.input(confirmation, {
|
||||||
|
target: { value: metadataFixture().display_name },
|
||||||
|
});
|
||||||
|
await fireEvent.click(remove);
|
||||||
|
await waitFor(() => expect(goto).toHaveBeenCalledWith("/"));
|
||||||
|
expect(api.mock.calls.filter(([, init]) => init?.method === "POST"))
|
||||||
|
.toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
api.mock.calls.some(([path]) => String(path).includes("/repositories")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("reload resumes persisted deletion without sending another POST", async () => {
|
test("reload resumes persisted deletion without sending another POST", async () => {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"yoi:workspace-deletion:home-owner",
|
"yoi:workspace-deletion:home-owner",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
parseTicketRoleAssignmentMutationResponse,
|
parseTicketRoleAssignmentMutationResponse,
|
||||||
} from "#lib/workspace/api/ticket-browser.ts";
|
} from "#lib/workspace/api/ticket-browser.ts";
|
||||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||||
|
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||||
import {
|
import {
|
||||||
relationLabel,
|
relationLabel,
|
||||||
TICKET_STATES,
|
TICKET_STATES,
|
||||||
@@ -360,7 +361,10 @@
|
|||||||
if (
|
if (
|
||||||
await mutate("state", "/state", {
|
await mutate("state", "/state", {
|
||||||
state: nextState,
|
state: nextState,
|
||||||
reason: transitionReason.trim() || null,
|
operation_key: crypto.randomUUID(),
|
||||||
|
expected_item_revision: ticket.item_revision,
|
||||||
|
expected_state: ticket.state,
|
||||||
|
reason: transitionReason.trim() || "Progress decision from the Ticket page",
|
||||||
})
|
})
|
||||||
) transitionReason = "";
|
) transitionReason = "";
|
||||||
}
|
}
|
||||||
@@ -380,7 +384,12 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!resolution.trim()) return;
|
if (!resolution.trim()) return;
|
||||||
if (
|
if (
|
||||||
await mutate("close", "/close", { resolution: resolution.trim() })
|
await mutate("close", "/close", {
|
||||||
|
resolution: resolution.trim(),
|
||||||
|
operation_key: crypto.randomUUID(),
|
||||||
|
expected_item_revision: ticket.item_revision,
|
||||||
|
expected_state: ticket.state,
|
||||||
|
})
|
||||||
) resolution = "";
|
) resolution = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +528,8 @@
|
|||||||
|
|
||||||
<aside class="ticket-control-rail">
|
<aside class="ticket-control-rail">
|
||||||
<section class="ticket-control-card ticket-worker-card">
|
<section class="ticket-control-card ticket-worker-card">
|
||||||
<header><h2>Role assignments</h2><span>Server-authoritative</span></header>
|
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
|
||||||
|
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
|
||||||
{#if ticket.assignments.length > 0}
|
{#if ticket.assignments.length > 0}
|
||||||
<ul class="ticket-assignment-list">
|
<ul class="ticket-assignment-list">
|
||||||
{#each ticket.assignments as assignment}
|
{#each ticket.assignments as assignment}
|
||||||
@@ -538,7 +548,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-empty-copy">No active role assignment.</p>
|
<p class="workspace-empty-copy">No role responsibility recorded.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ticket.action_eligibility.can_assign_orchestrator}
|
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||||
<button
|
<button
|
||||||
@@ -681,6 +691,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<section class="ticket-control-card">
|
||||||
|
<header><h2>Current requirement evidence</h2></header>
|
||||||
|
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
|
||||||
|
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
|
||||||
|
{#if ticket.evidence.missing.length > 0}
|
||||||
|
<ul>
|
||||||
|
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
<p class="workspace-empty-copy">This evidence evaluates current Ticket requirements, separately from persisted integration approval.</p>
|
||||||
|
{#if ticket.state === "done"}
|
||||||
|
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
<header><h2>Merge Requests</h2></header>
|
<header><h2>Merge Requests</h2></header>
|
||||||
{#if mergeRequests.length > 0}
|
{#if mergeRequests.length > 0}
|
||||||
@@ -691,17 +716,16 @@
|
|||||||
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
|
||||||
to <code>{mergeRequest.selector_to}</code>
|
to <code>{mergeRequest.selector_to}</code>
|
||||||
</p>
|
</p>
|
||||||
{#if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref === mergeRequest.current_subject_ref}
|
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
|
||||||
<p><strong>Source review:</strong> {mergeRequest.review_status} for exact ref <code>{mergeRequest.current_subject_ref}</code></p>
|
{#if mergeRequest.integration_evidence_error !== null}
|
||||||
{:else if mergeRequest.current_subject_ref && mergeRequest.review_subject_ref}
|
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
|
||||||
<p><strong>Fresh source review required:</strong> selector_from moved from <code>{mergeRequest.review_subject_ref}</code> to <code>{mergeRequest.current_subject_ref}</code>.</p>
|
|
||||||
{:else if mergeRequest.current_subject_ref}
|
|
||||||
<p><strong>Fresh source review required:</strong> no effective verdict exists for <code>{mergeRequest.current_subject_ref}</code>.</p>
|
|
||||||
{:else}
|
|
||||||
<p><strong>Source review unavailable:</strong> selector_from is unresolved.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
|
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
|
||||||
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
|
{#if mergeRequest.state === "merged"}
|
||||||
|
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
|
||||||
|
{/if}
|
||||||
<a
|
<a
|
||||||
class="workspace-secondary-button"
|
class="workspace-secondary-button"
|
||||||
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import NewWorker from "./+page.svelte";
|
||||||
|
function mount() {
|
||||||
|
const data = {
|
||||||
|
workspaceId: "empty",
|
||||||
|
repositories: {
|
||||||
|
workspace_id: "empty",
|
||||||
|
items: [],
|
||||||
|
source: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
ticketContext: null,
|
||||||
|
} as unknown as ComponentProps<typeof NewWorker>["data"];
|
||||||
|
return render(NewWorker, {
|
||||||
|
props: { data, params: { workspaceId: "empty" } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
import { emptyLaunchOptions } from "#lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero-repository Workspace launches a normal embedded Worker request without Workdir or Ticket assignment", async () => {
|
||||||
|
const api = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||||
|
String(input).endsWith("/launch-options")
|
||||||
|
? Response.json(emptyLaunchOptions("empty"))
|
||||||
|
: Response.json({
|
||||||
|
error: "unavailable",
|
||||||
|
message: "Retry later",
|
||||||
|
diagnostics: [],
|
||||||
|
}, { status: 503 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", api);
|
||||||
|
mount();
|
||||||
|
const start = await screen.findByRole("button", { name: "Start Worker" });
|
||||||
|
expect((start as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"No filesystem tools or Bash will be available without a Workdir.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText("Attachment 1 Workdir")).toBeNull();
|
||||||
|
await fireEvent.click(start);
|
||||||
|
await waitFor(() => expect(api).toHaveBeenCalledTimes(2));
|
||||||
|
const body = JSON.parse(String(api.mock.calls[1][1]?.body));
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
runtime_id: "embedded",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
workdir_attachments: [],
|
||||||
|
ticket_assignment: null,
|
||||||
|
});
|
||||||
|
expect(body).not.toHaveProperty("repository_key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero-repository Workspace does not bypass a remote Runtime Workdir requirement", async () => {
|
||||||
|
const options = emptyLaunchOptions("empty");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
Response.json({ ...options, runtimes: [...options.runtimes].reverse() })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
await screen.findByRole("button", { name: "Start Worker" });
|
||||||
|
await screen.findByLabelText("Attachment 1 Workdir");
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", { name: "Start Worker" }) as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import Catalog from "./+page.svelte";
|
||||||
|
import {
|
||||||
|
createdWorkspace,
|
||||||
|
creationResponse,
|
||||||
|
} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
function mount() {
|
||||||
|
return render(Catalog, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
accessibleWorkspaces: [createdWorkspace],
|
||||||
|
workspaceCatalogError: null,
|
||||||
|
workspaces: [{ ...createdWorkspace, repositories: [] }],
|
||||||
|
catalogError: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function submit() {
|
||||||
|
return fireEvent.submit(
|
||||||
|
screen.getByRole("button", { name: /Create Workspace|Retry creation/ })
|
||||||
|
.closest("form")!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("blank repository form creates and opens a Workspace using a null repository response", async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json(creationResponse()),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
mount();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /New Workspace.*No repositories/ })
|
||||||
|
.getAttribute("href"),
|
||||||
|
).toBe("/w/home-empty");
|
||||||
|
expect(screen.queryByLabelText("Repository key")).toBeNull();
|
||||||
|
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||||
|
target: { value: " New Workspace " },
|
||||||
|
});
|
||||||
|
await submit();
|
||||||
|
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toMatchObject({
|
||||||
|
display_name: "New Workspace",
|
||||||
|
repository: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opting into a repository requires an explicit key and source and submits normalized values", async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||||
|
Response.json(creationResponse()),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
mount();
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole("checkbox", {
|
||||||
|
name: "Add an initial repository (optional)",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const key = screen.getByLabelText("Repository key") as HTMLInputElement;
|
||||||
|
const uri = screen.getByLabelText(
|
||||||
|
"Initial repository absolute path or URI",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(key.required && uri.required).toBe(true);
|
||||||
|
expect(key.value).toBe("");
|
||||||
|
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||||
|
target: { value: "New Workspace" },
|
||||||
|
});
|
||||||
|
await fireEvent.input(key, { target: { value: "platform" } });
|
||||||
|
await fireEvent.input(uri, { target: { value: " /srv/platform " } });
|
||||||
|
await fireEvent.input(screen.getByLabelText("Default ref"), {
|
||||||
|
target: { value: " main " },
|
||||||
|
});
|
||||||
|
await submit();
|
||||||
|
await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).repository).toEqual(
|
||||||
|
{ repository_key: "platform", uri: "/srv/platform", default_ref: "main" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unchanged empty creation retries reuse the operation key and accept deleted-repository replay", async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
Response.json({ message: "Temporary failure" }, { status: 503 }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(Response.json(creationResponse(null, true)));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
mount();
|
||||||
|
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||||
|
target: { value: "New Workspace" },
|
||||||
|
});
|
||||||
|
await submit();
|
||||||
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||||
|
await submit();
|
||||||
|
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
|
||||||
|
expect(
|
||||||
|
fetcher.mock.calls.map(([, init]) => JSON.parse(String(init?.body)))[0],
|
||||||
|
).toEqual(JSON.parse(String(fetcher.mock.calls[1][1]?.body)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opting out ignores retained repository inputs and changes the creation operation", async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||||
|
Response.json({ message: "Temporary failure" }, { status: 503 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
mount();
|
||||||
|
const toggle = screen.getByRole("checkbox", {
|
||||||
|
name: "Add an initial repository (optional)",
|
||||||
|
});
|
||||||
|
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||||
|
target: { value: "New Workspace" },
|
||||||
|
});
|
||||||
|
await fireEvent.click(toggle);
|
||||||
|
await fireEvent.input(screen.getByLabelText("Repository key"), {
|
||||||
|
target: { value: "platform" },
|
||||||
|
});
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByLabelText("Initial repository absolute path or URI"),
|
||||||
|
{ target: { value: "/srv/platform" } },
|
||||||
|
);
|
||||||
|
await submit();
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
await fireEvent.click(toggle);
|
||||||
|
await submit();
|
||||||
|
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||||
|
const bodies = fetcher.mock.calls.map(([, init]) =>
|
||||||
|
JSON.parse(String(init?.body))
|
||||||
|
);
|
||||||
|
expect(bodies[1].repository).toBeNull();
|
||||||
|
expect(bodies[1].operation_key).not.toBe(bodies[0].operation_key);
|
||||||
|
});
|
||||||
@@ -48,3 +48,15 @@ Deno.test("ticket detail fences stale mutation responses", () => {
|
|||||||
assertStringIncludes(operationSource, "generation === routeGeneration");
|
assertStringIncludes(operationSource, "generation === routeGeneration");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
|
||||||
|
assertStringIncludes(pageSource, "Retained responsibility");
|
||||||
|
assertStringIncludes(pageSource, "after work ends and Worker removal");
|
||||||
|
assertStringIncludes(
|
||||||
|
pageSource,
|
||||||
|
"does not indicate a running Worker or active work authority",
|
||||||
|
);
|
||||||
|
assertStringIncludes(pageSource, "assignment.principal.runtime_id");
|
||||||
|
assertStringIncludes(pageSource, "assignment.principal.worker_id");
|
||||||
|
assert(!pageSource.includes("No active role assignment."));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Optional initial repository validation (T-717 Web portion)
|
||||||
|
|
||||||
|
These scenarios use the production static Workspace build and the existing
|
||||||
|
`tools/web-ux` capture/process harness. The loopback proxy only extends the shared
|
||||||
|
Home fixture's API responses for empty Worker lists and launch options; it does
|
||||||
|
not replace the application shell, styles, or components. No real Workspace,
|
||||||
|
account, repository, Runtime, or Worker is created.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From `web/workspace`, with the repository's pinned Chromium available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deno task build
|
||||||
|
deno task test:browser:empty-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser test covers light/dark at 1440, 768, 390, and 320px: optional Git
|
||||||
|
native validation, opting out after entering repository values, null creation
|
||||||
|
response/navigation, zero-repository catalog reopening, normal embedded Worker
|
||||||
|
submission without Workdir or Ticket assignment, and remote Runtime Workdir
|
||||||
|
requirements. It uses dynamically allocated loopback ports and the existing
|
||||||
|
owned-process cleanup harness. Screenshots and logs are ignored artifacts under
|
||||||
|
`.svelte-kit/t717/browser`.
|
||||||
|
|
||||||
|
From `tools/web-ux`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deno task web-ux capture \
|
||||||
|
--scenario ../../web/workspace/test/visual-review/optional-repository.json \
|
||||||
|
--output ../../web/workspace/.svelte-kit/t717/visual --run-id empty-workspace
|
||||||
|
|
||||||
|
deno task web-ux capture \
|
||||||
|
--scenario ../../web/workspace/test/visual-review/optional-repository-interactions.json \
|
||||||
|
--output ../../web/workspace/.svelte-kit/t717/visual --run-id creation-interactions
|
||||||
|
```
|
||||||
|
|
||||||
|
The interaction scenario intentionally rejects creation with HTTP400. Capture
|
||||||
|
exit status 2 and the recorded request/console/visible validation errors are
|
||||||
|
expected for `validation-error`, not evidence of a successful creation. No other
|
||||||
|
capture error is expected. To repeat dark captures, use a scenario copy with
|
||||||
|
`colorScheme: "dark"`, preserving the process cwd paths.
|
||||||
|
|
||||||
|
## Implementer-owned visual review
|
||||||
|
|
||||||
|
- Baseline revision: `e939cd4e4b1f6b63b81411fd7766062f2d69ad0d`.
|
||||||
|
- Before: `.svelte-kit/t717/visual/before-final-light` and `before-dark`.
|
||||||
|
These use an isolated build under `.svelte-kit/t717/baseline-source`, with the
|
||||||
|
changed production form, catalog API, model, and CSS restored from baseline
|
||||||
|
Git source. The original pre-edit light capture is also retained as
|
||||||
|
`.svelte-kit/t717/visual/before-valid`.
|
||||||
|
- After: `.svelte-kit/t717/visual/final-light`, `final-dark`,
|
||||||
|
`final-interactions-light`, and `final-interactions-dark`.
|
||||||
|
- Comparisons: `.svelte-kit/t717/visual/final-comparison-light` and
|
||||||
|
`final-comparison-dark`.
|
||||||
|
- Personas: synthetic owner and non-owner permission projections; catalog
|
||||||
|
creation/reopening uses the same synthetic account.
|
||||||
|
- Viewports: 1440, 768, 390px; additional browser screenshots/checks at 320px.
|
||||||
|
- Themes: light and dark.
|
||||||
|
- States: zero repositories, empty Home/Sidebar, representative member work
|
||||||
|
records, long catalog names, unchecked/checked optional repository form,
|
||||||
|
populated inputs, and intentional Backend validation failure.
|
||||||
|
- Reviewed by the implementer using `ViewImage`: before/after contact sheets,
|
||||||
|
individual narrow/mobile populated-form screenshots, and the final mobile
|
||||||
|
dark validation-error screenshot. Reviewed context bundles and accessibility
|
||||||
|
snapshots for document status, control names/state, screenshot hashes,
|
||||||
|
console/page/request errors, and capture points.
|
||||||
|
- Fixed: catalog/form clipping at narrow content widths; repository field
|
||||||
|
disclosure/required-state handling. Chromium tests also found and fixed the
|
||||||
|
existing repository-key HTML pattern's invalid modern-regexp character class.
|
||||||
|
- Result: pass. All baseline and final main captures have document status 200
|
||||||
|
and no retained browser/UI errors. Only the intentional HTTP400 interaction
|
||||||
|
errors remain. Home, member Sidebar, and Worker launch comparisons show zero
|
||||||
|
changed pixels; catalog changes are confined to the intended surface.
|
||||||
|
|
||||||
|
## Validation and limitations
|
||||||
|
|
||||||
|
- `deno task check`: 0 errors, 0 warnings.
|
||||||
|
- Targeted model/catalog Deno tests: 18 passed.
|
||||||
|
- `deno task test:component`: 29 files, 268 tests passed.
|
||||||
|
- `deno task test:browser:empty-workspace`: passed (8 theme/width combinations).
|
||||||
|
- `deno task build`: passed.
|
||||||
|
- Web-scoped `git diff --check HEAD -- .`: passed.
|
||||||
|
- `deno task test`: 522 passed, 1 pre-existing failure in
|
||||||
|
`src/lib/workspace/console/worker-console.ui.test.ts`,
|
||||||
|
“workspace Tickets surface provides Kanban and lifecycle controls”. It fails
|
||||||
|
the unchanged Ticket detail source assertion at line 429. The same failure
|
||||||
|
was reproduced against the isolated baseline source. Evidence:
|
||||||
|
`.svelte-kit/t717/unit.log` and `baseline-ticket-failure.log`.
|
||||||
|
- Canonical OpenAPI and generated TypeScript were regenerated together.
|
||||||
|
The Web parser uses the generated nullable creation-response type directly.
|
||||||
|
Backend persistence/idempotency is covered separately by Store/catalog and
|
||||||
|
real HTTP boundary tests in `yoi-workspace-server`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Extends the existing Web UX fixture at the API boundary, never the production shell/CSS.
|
||||||
|
// Both loopback servers are owned and cleaned up by the Web UX capture harness.
|
||||||
|
const port = Number(Deno.args[0]);
|
||||||
|
const upstream = Deno.args[1];
|
||||||
|
if (!Number.isInteger(port) || !upstream) {
|
||||||
|
throw new Error("usage: server <port> <upstream-url>");
|
||||||
|
}
|
||||||
|
|
||||||
|
import { emptyLaunchOptions } from "../../src/lib/workspace/sidebar/worker-launch.test-fixtures.ts";
|
||||||
|
import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||||
|
|
||||||
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/api/workspaces" && request.method === "POST") {
|
||||||
|
return Response.json({
|
||||||
|
message:
|
||||||
|
"Workspace display name was rejected by Backend validation. Choose another display name and retry; no Workspace was created.",
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, id, path] = match;
|
||||||
|
// The shared catalog includes a deliberate failure persona, which can be hover-preloaded.
|
||||||
|
// This scenario tests zero repositories, not that separate failure contract.
|
||||||
|
if (id === "home-error" && path === "/merge-requests") {
|
||||||
|
return Response.json(
|
||||||
|
dashboardFixture(path, url.searchParams, "home-empty"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === "/workers") {
|
||||||
|
return Response.json({
|
||||||
|
workspace_id: id,
|
||||||
|
limit: 100,
|
||||||
|
items: [],
|
||||||
|
source: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/workers/launch-options") {
|
||||||
|
return Response.json(emptyLaunchOptions(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Forward HTTP and bridge the existing workspace subscription fixture.
|
||||||
|
const target = new URL(url.pathname + url.search, upstream);
|
||||||
|
if (request.headers.get("upgrade") === "websocket") {
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
target.protocol = "ws:";
|
||||||
|
const peer = new WebSocket(target);
|
||||||
|
const pending: string[] = [];
|
||||||
|
socket.onmessage = (event) =>
|
||||||
|
peer.readyState === WebSocket.OPEN
|
||||||
|
? peer.send(event.data)
|
||||||
|
: pending.push(String(event.data));
|
||||||
|
peer.onopen = () => {
|
||||||
|
for (const frame of pending) peer.send(frame);
|
||||||
|
};
|
||||||
|
peer.onmessage = (event) => {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) socket.send(event.data);
|
||||||
|
};
|
||||||
|
socket.onclose = () => peer.close();
|
||||||
|
peer.onclose = () => socket.close();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return await fetch(new Request(target, request));
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||||
|
import { chromium } from "npm:playwright@1.59.1";
|
||||||
|
import { loadScenario } from "../../../../tools/web-ux/src/scenario.ts";
|
||||||
|
import {
|
||||||
|
startOwnedProcesses,
|
||||||
|
stopOwnedProcesses,
|
||||||
|
} from "../../../../tools/web-ux/src/processes.ts";
|
||||||
|
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||||
|
|
||||||
|
// Browser-only contract over the production static build and loopback API fixtures;
|
||||||
|
// no actual Backend, Runtime, Worker, account or repository is created.
|
||||||
|
Deno.test("production empty Workspace supports optional Git validation, reopening, and Workdir-less Worker submission at all review widths", async () => {
|
||||||
|
const scenarioPath =
|
||||||
|
new URL("./optional-repository.json", import.meta.url).pathname;
|
||||||
|
const scenario = await loadScenario(scenarioPath);
|
||||||
|
async function freePort() {
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
const upstreamPort = await freePort();
|
||||||
|
let proxyPort = await freePort();
|
||||||
|
while (proxyPort === upstreamPort) proxyPort = await freePort();
|
||||||
|
scenario.baseUrl = `http://127.0.0.1:${proxyPort}`;
|
||||||
|
scenario.processes![0].args![4] = String(upstreamPort);
|
||||||
|
scenario.processes![0].readyUrl = `http://127.0.0.1:${upstreamPort}/health`;
|
||||||
|
scenario.processes![1].args![3] = String(proxyPort);
|
||||||
|
scenario.processes![1].args![4] = `http://127.0.0.1:${upstreamPort}`;
|
||||||
|
scenario.processes![1].readyUrl = `${scenario.baseUrl}/health`;
|
||||||
|
const output =
|
||||||
|
new URL("../../.svelte-kit/t717/browser/", import.meta.url).pathname;
|
||||||
|
const processes = await startOwnedProcesses(
|
||||||
|
scenario.processes!,
|
||||||
|
scenarioPath,
|
||||||
|
`${output}/process-logs`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
errors.push(`${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const creationBodies: Record<string, unknown>[] = [];
|
||||||
|
await page.route("**/api/workspaces", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
creationBodies.push(route.request().postDataJSON());
|
||||||
|
await route.fulfill({ json: creationResponse() });
|
||||||
|
});
|
||||||
|
await page.goto(scenario.baseUrl!);
|
||||||
|
const name = page.getByLabel("Workspace display name");
|
||||||
|
await name.fill("New Workspace");
|
||||||
|
const toggle = page.getByRole("checkbox", {
|
||||||
|
name: "Add an initial repository (optional)",
|
||||||
|
});
|
||||||
|
await toggle.check();
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Create Workspace",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
assertEquals(creationBodies.length, 0); // Native required fields stop partial repository submission.
|
||||||
|
await page.getByLabel("Repository key", { exact: true }).fill(
|
||||||
|
"Invalid key",
|
||||||
|
);
|
||||||
|
await page.getByLabel("Initial repository absolute path or URI")
|
||||||
|
.fill("/srv/platform");
|
||||||
|
assertEquals(
|
||||||
|
await page.getByLabel("Repository key", { exact: true }).evaluate(
|
||||||
|
(input) =>
|
||||||
|
(input as unknown as { checkValidity(): boolean })
|
||||||
|
.checkValidity(),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await page.getByLabel("Repository key", { exact: true }).fill(
|
||||||
|
"platform",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await page.locator("form").evaluate((form) =>
|
||||||
|
(form as unknown as { checkValidity(): boolean })
|
||||||
|
.checkValidity()
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await Deno.mkdir(output, { recursive: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${output}/${colorScheme}-${width}-repository.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
await page.evaluate(
|
||||||
|
"document.documentElement.scrollWidth > innerWidth",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
`${colorScheme} ${width}px`,
|
||||||
|
);
|
||||||
|
await toggle.uncheck();
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Create Workspace",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await page.waitForURL("**/w/home-empty");
|
||||||
|
assertEquals(creationBodies.length, 1);
|
||||||
|
assertEquals(creationBodies[0].repository, null);
|
||||||
|
await page.locator("[data-feed-ready=home-active]").waitFor();
|
||||||
|
assertEquals(await page.locator("main [role=alert]").count(), 0);
|
||||||
|
// Reopening is based on Workspace identity, not a repository record.
|
||||||
|
await page.goto(scenario.baseUrl!);
|
||||||
|
await page.locator('a.workspace-catalog-card[href="/w/home-empty"]')
|
||||||
|
.click();
|
||||||
|
await page.waitForURL("**/w/home-empty");
|
||||||
|
await page.goto(`${scenario.baseUrl}/w/home-empty/workers/new`);
|
||||||
|
await page.locator(".worker-launch-form").waitFor();
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", { name: "Start Worker" })
|
||||||
|
.isEnabled(),
|
||||||
|
);
|
||||||
|
await page.getByRole("combobox", { name: "Runtime", exact: true })
|
||||||
|
.selectOption("remote");
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", { name: "Start Worker" })
|
||||||
|
.isDisabled(),
|
||||||
|
);
|
||||||
|
await page.getByLabel("Attachment 1 Workdir").selectOption(
|
||||||
|
"__new_working_directory__",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Create Workdir",
|
||||||
|
exact: true,
|
||||||
|
}).isDisabled(),
|
||||||
|
);
|
||||||
|
await page.getByRole("combobox", { name: "Runtime", exact: true })
|
||||||
|
.selectOption("embedded");
|
||||||
|
const submitted = page.waitForRequest((request) =>
|
||||||
|
request.url().endsWith("/api/w/home-empty/workers") &&
|
||||||
|
request.method() === "POST"
|
||||||
|
);
|
||||||
|
await page.route("**/api/w/home-empty/workers", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
return await route.continue();
|
||||||
|
}
|
||||||
|
// Successful, synthetic launch projection; the test only exercises browser/API wiring.
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
workspace_id: "home-empty",
|
||||||
|
runtime_id: "embedded",
|
||||||
|
worker_id: "fixture-worker",
|
||||||
|
console_href: "/w/home-empty",
|
||||||
|
diagnostics: [],
|
||||||
|
worker: {
|
||||||
|
runtime_id: "embedded",
|
||||||
|
worker_id: "fixture-worker",
|
||||||
|
host_id: "fixture-host",
|
||||||
|
display_name: "Worker",
|
||||||
|
label: "Worker",
|
||||||
|
profile: "builtin:companion",
|
||||||
|
singleton_key: null,
|
||||||
|
tags: [],
|
||||||
|
workspace: {
|
||||||
|
visibility: "workspace",
|
||||||
|
identity: "home-empty",
|
||||||
|
workspace_id: "home-empty",
|
||||||
|
},
|
||||||
|
state: "idle",
|
||||||
|
last_seen_at: null,
|
||||||
|
pinned: false,
|
||||||
|
retention_state: "active",
|
||||||
|
implementation: { kind: "yoi", display_hint: "Yoi" },
|
||||||
|
workdir_attachments: [],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Start Worker" }).click();
|
||||||
|
const body = (await submitted).postDataJSON();
|
||||||
|
assertEquals(body.workdir_attachments, []);
|
||||||
|
assertEquals(body.ticket_assignment, null);
|
||||||
|
assertEquals(body.runtime_id, "embedded");
|
||||||
|
await page.waitForURL("**/w/home-empty");
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
assertEquals(await stopOwnedProcesses(processes), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "optional-repository-interactions",
|
||||||
|
"title": "Optional repository disclosure and validation failure",
|
||||||
|
"baseUrl": "http://127.0.0.1:15281",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Synthetic account; permissions vary by Workspace",
|
||||||
|
"auth": {
|
||||||
|
"kind": "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"label": "desktop",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "narrow",
|
||||||
|
"width": 768,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "mobile",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"id": "catalog",
|
||||||
|
"label": "Create Workspace without a repository",
|
||||||
|
"path": "/",
|
||||||
|
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
|
||||||
|
"dataState": "Opt-in Git fields followed by intentional HTTP400 creation validation failure; no actual resources are created.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-create-panel input"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "repository-fields",
|
||||||
|
"label": "Opt-in Git fields",
|
||||||
|
"fullPage": true,
|
||||||
|
"interaction": [
|
||||||
|
{
|
||||||
|
"action": "click",
|
||||||
|
"selector": ".workspace-repository-toggle input"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "validation-error",
|
||||||
|
"label": "Intentional Backend validation failure",
|
||||||
|
"fullPage": true,
|
||||||
|
"interaction": [
|
||||||
|
{
|
||||||
|
"action": "click",
|
||||||
|
"selector": ".workspace-repository-toggle input"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "fill",
|
||||||
|
"selector": ".workspace-create-panel input[required]",
|
||||||
|
"value": "Rejected Workspace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "click",
|
||||||
|
"selector": ".workspace-create-panel button[type=submit]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-create-panel [role=alert]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"id": "workspace-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../../../../tools/web-ux",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/workspace_home_fixture_server.ts",
|
||||||
|
"15282",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15282/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty-workspace-api",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"test/visual-review/empty-workspace-fixture-server.ts",
|
||||||
|
"15281",
|
||||||
|
"http://127.0.0.1:15282"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15281/health"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "optional-initial-repository",
|
||||||
|
"title": "Optional initial repository and empty Workspace",
|
||||||
|
"baseUrl": "http://127.0.0.1:15281",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"id": "fixture",
|
||||||
|
"label": "Synthetic account; permissions vary by Workspace",
|
||||||
|
"auth": {
|
||||||
|
"kind": "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{
|
||||||
|
"label": "desktop",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "narrow",
|
||||||
|
"width": 768,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "mobile",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"id": "catalog",
|
||||||
|
"label": "Create Workspace without a repository",
|
||||||
|
"path": "/",
|
||||||
|
"goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.",
|
||||||
|
"dataState": "Accessible Workspaces with no repositories and blank creation form.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-create-panel input"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Blank creation form",
|
||||||
|
"fullPage": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty-owner",
|
||||||
|
"label": "Empty Workspace owner",
|
||||||
|
"path": "/w/home-empty",
|
||||||
|
"goal": "Use Home and Sidebar without any repositories or work records.",
|
||||||
|
"dataState": "Owner permissions; zero repositories, Workers, tickets, Objectives, and Merge Requests.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Empty Home and Sidebar"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "member",
|
||||||
|
"label": "Zero-repository member",
|
||||||
|
"path": "/w/home-member",
|
||||||
|
"goal": "Navigate authorized work without owner-only repository management actions.",
|
||||||
|
"dataState": "Non-owner permissions; zero repositories, representative work records.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Member Home and Sidebar"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "worker",
|
||||||
|
"label": "New Worker without repositories",
|
||||||
|
"path": "/w/home-empty/workers/new",
|
||||||
|
"goal": "Start a normal conversation Worker without a Workdir on embedded Runtime.",
|
||||||
|
"dataState": "No repositories or Workdirs, embedded and remote Runtime candidates.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".worker-launch-form"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{
|
||||||
|
"id": "initial",
|
||||||
|
"label": "Embedded Worker launch",
|
||||||
|
"fullPage": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"id": "workspace-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../../../../tools/web-ux",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/workspace_home_fixture_server.ts",
|
||||||
|
"15282",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15282/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty-workspace-api",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"test/visual-review/empty-workspace-fixture-server.ts",
|
||||||
|
"15281",
|
||||||
|
"http://127.0.0.1:15282"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15281/health"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => void | Promise<void>): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
readTextFile(path: URL): Promise<string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): asserts condition {
|
|
||||||
if (!condition) throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertEquals(actual: unknown, expected: unknown): void {
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -35,6 +30,26 @@ import {
|
|||||||
loadWorkspaceCatalog,
|
loadWorkspaceCatalog,
|
||||||
WorkspaceCatalogError,
|
WorkspaceCatalogError,
|
||||||
} from "../src/lib/workspace/api/workspace-catalog.ts";
|
} from "../src/lib/workspace/api/workspace-catalog.ts";
|
||||||
|
import { creationResponse } from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||||
|
|
||||||
|
Deno.test("workspace creation accepts omitted and null repository requests without fabricating a repository", async () => {
|
||||||
|
for (const repository of [undefined, null]) {
|
||||||
|
const request = {
|
||||||
|
operation_key: "empty-create",
|
||||||
|
display_name: "New Workspace",
|
||||||
|
...(repository === null ? { repository } : {}),
|
||||||
|
};
|
||||||
|
const fetcher = async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
assertEquals(url, "/api/workspaces");
|
||||||
|
assertEquals(init?.method, "POST");
|
||||||
|
assertEquals(JSON.parse(String(init?.body)), request);
|
||||||
|
return Response.json(creationResponse());
|
||||||
|
};
|
||||||
|
const response = await createWorkspace(fetcher as typeof fetch, request);
|
||||||
|
assertEquals(response.repository, null);
|
||||||
|
assertEquals(response.workspace.workspace_id, "home-empty");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("workspace catalog enriches each visible workspace without dropping siblings", async () => {
|
Deno.test("workspace catalog enriches each visible workspace without dropping siblings", async () => {
|
||||||
const fetcher = (input: string | URL | Request) => {
|
const fetcher = (input: string | URL | Request) => {
|
||||||
@@ -118,27 +133,3 @@ Deno.test("workspace creation preserves caller-owned operation key across retry"
|
|||||||
);
|
);
|
||||||
assertEquals(bodies, [request, request]);
|
assertEquals(bodies, [request, request]);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("workspace creation form starts with an explicit required Repository key", async () => {
|
|
||||||
const source = await Deno.readTextFile(
|
|
||||||
new URL("../src/routes/+page.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
source.includes('let repositoryKey = $state("");'),
|
|
||||||
"the form must start without an implicit Repository key",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
source.includes("<input bind:value={repositoryKey} required"),
|
|
||||||
"the Browser must require creator input for the Repository key",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
source.includes("repository_key: normalized.repositoryKey"),
|
|
||||||
"the Browser must submit the creator-provided Repository key",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
!source.includes('repositoryKey || "main"') &&
|
|
||||||
!source.includes("repositoryKey || 'main'"),
|
|
||||||
"the Browser must not fall back to main",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -8,12 +8,57 @@ import {
|
|||||||
parseRepositoryApiError,
|
parseRepositoryApiError,
|
||||||
parseRepositoryListApiResult,
|
parseRepositoryListApiResult,
|
||||||
parseRepositoryListResponse,
|
parseRepositoryListResponse,
|
||||||
|
parseWorkspaceCreateResponse,
|
||||||
parseWorkspaceDeletionOperationResponse,
|
parseWorkspaceDeletionOperationResponse,
|
||||||
parseWorkspaceDeletionPreflightResponse,
|
parseWorkspaceDeletionPreflightResponse,
|
||||||
parseWorkspaceResponse,
|
parseWorkspaceResponse,
|
||||||
REPOSITORY_API_LIMITS,
|
REPOSITORY_API_LIMITS,
|
||||||
} from "../src/lib/workspace/api/workspace-model.ts";
|
} from "../src/lib/workspace/api/workspace-model.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
creationResponse,
|
||||||
|
currentInitialRepository,
|
||||||
|
} from "../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||||
|
|
||||||
|
Deno.test("workspace creation decodes absent and deleted initial repositories as explicit null", () => {
|
||||||
|
for (const replayed of [false, true]) {
|
||||||
|
const parsed = parseWorkspaceCreateResponse(
|
||||||
|
JSON.parse(JSON.stringify(creationResponse(null, replayed))),
|
||||||
|
);
|
||||||
|
if (parsed.repository !== null || parsed.replayed !== replayed) {
|
||||||
|
throw new Error("nullable creation response was not preserved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace creation replay preserves the returned current repository record", () => {
|
||||||
|
const parsed = parseWorkspaceCreateResponse(
|
||||||
|
creationResponse(currentInitialRepository, true),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
JSON.stringify(parsed.repository) !==
|
||||||
|
JSON.stringify(currentInitialRepository)
|
||||||
|
) throw new Error("current repository record was replaced or lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace creation rejects missing and malformed repository response fields", () => {
|
||||||
|
for (
|
||||||
|
const value of [undefined, false, {}, {
|
||||||
|
...currentInitialRepository,
|
||||||
|
source_revision: -1,
|
||||||
|
}]
|
||||||
|
) {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseWorkspaceCreateResponse({
|
||||||
|
...creationResponse(),
|
||||||
|
repository: value,
|
||||||
|
}),
|
||||||
|
"workspace create response.repository",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function assertThrows(operation: () => unknown, expected: string): void {
|
function assertThrows(operation: () => unknown, expected: string): void {
|
||||||
try {
|
try {
|
||||||
operation();
|
operation();
|
||||||
|
|||||||
Reference in New Issue
Block a user