chore: merge develop preserving documentation structure and console UX
Integrate develop at 523bade3 while retaining concise agent guidance, rustdoc-owned design rationale, and current console history/rewind behavior. Adapt incoming tests to public entry-ID rewind and synchronize OpenAPI/TypeScript artifacts.
Validation: default-member cargo test --all-features (4936 passed, 23 ignored), root cargo check, all-feature rustdoc with broken links denied, format/diff/name guards, OpenAPI and 11 TypeScript freshness checks. Web: 613 unit, 327 component, 7 Ticket browser and 3 Console browser tests; check/build passed. Merged-state Ticket visual review at 1440/768/390px in light/dark; no matching pre-merge image baseline. E2E/live-provider checks not run.
This commit is contained in:
@@ -37,6 +37,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
||||
agen-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
+171
-127
@@ -88,6 +88,63 @@ pub enum EngineError {
|
||||
ToolAttemptFence(String),
|
||||
}
|
||||
|
||||
/// Policy completion is not permission to start work when an interruption was
|
||||
/// queued during its final poll. Cancel wins when both requests are observed.
|
||||
fn check_policy_interruption(
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<(), EngineError> {
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if pause_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll one policy invocation, never a second copy of its side effects. Dropping
|
||||
/// a pending policy stops admission, not any already-owned tool execution; the
|
||||
/// caller must drain that execution through its original attempt fence.
|
||||
async fn await_tool_policy<T>(
|
||||
policy: impl Future<Output = Result<T, InterceptorError>>,
|
||||
phase: InterceptorPhase,
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<T, EngineError> {
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
Some(()) = cancel_rx.recv() => return Err(EngineError::Cancelled),
|
||||
Some(()) = pause_rx.recv() => return Err(EngineError::PauseRequested),
|
||||
result = policy => result,
|
||||
};
|
||||
check_policy_interruption(cancel_rx, pause_rx)?;
|
||||
result.map_err(|error| InterceptorFailure::new(phase, error).into())
|
||||
}
|
||||
|
||||
/// A consumed Pause still bounds post-policy processing while owned tools drain.
|
||||
/// Terminal history and publication happen before this wait, so expiring it
|
||||
/// cannot discard the result or reopen the execution attempt.
|
||||
#[derive(Clone, Copy)]
|
||||
enum PostToolPolicyWait {
|
||||
Skip,
|
||||
Interruptible,
|
||||
UntilPauseSafeBoundary(TokioInstant),
|
||||
}
|
||||
|
||||
impl PostToolPolicyWait {
|
||||
fn for_pause_deadline(deadline: Option<TokioInstant>) -> Self {
|
||||
deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary)
|
||||
}
|
||||
|
||||
fn pause_deadline(self) -> Option<TokioInstant> {
|
||||
match self {
|
||||
Self::UntilPauseSafeBoundary(deadline) => Some(deadline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool registration error
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolRegistryError {
|
||||
@@ -770,14 +827,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_paused(&mut self) -> bool {
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match self.pause_rx.try_recv() {
|
||||
Ok(()) => true,
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a text block observer with scoped callbacks.
|
||||
///
|
||||
/// The setup closure is called once per text block. Inside it, register
|
||||
@@ -975,10 +1024,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
/// Register a callback invoked once per completed tool execution.
|
||||
///
|
||||
/// Fired after `post_tool_call` interceptors and any `content`
|
||||
/// truncation from `tool_output_limits`, so the callback observes
|
||||
/// exactly what is persisted to history. Intended for callers that need
|
||||
/// to forward tool results to clients.
|
||||
/// Fired after `content` truncation from `tool_output_limits` and terminal
|
||||
/// history commit, before `post_tool_call` policy. The callback observes
|
||||
/// exactly what is persisted even if that policy is interrupted. Intended
|
||||
/// for callers that need to forward tool results to clients.
|
||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||
self.tool_result_cbs.push(Box::new(callback));
|
||||
}
|
||||
@@ -1552,43 +1601,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool,
|
||||
context,
|
||||
};
|
||||
let action = tokio::select! {
|
||||
result = self.interceptor.pre_tool_call(&mut info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Paused while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
let action = await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let action = match action {
|
||||
Ok(action) => action,
|
||||
Err(error @ (EngineError::PauseRequested | EngineError::Cancelled)) => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Cancelled while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
batch.dispatched_any = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
return match error {
|
||||
EngineError::PauseRequested => Ok(EarlyToolAdmission::Pause),
|
||||
_ => Err(error),
|
||||
};
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
@@ -1737,7 +1767,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1764,7 +1794,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1812,7 +1842,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1835,7 +1865,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1858,28 +1888,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
force_cancel: bool,
|
||||
initial_pause_requested: bool,
|
||||
) -> Result<ToolExecutionResult, EngineError> {
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
if force_cancel {
|
||||
if let Some(error) = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
@@ -1893,6 +1901,28 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let mut pause_requested = initial_pause_requested;
|
||||
let mut pause_deadline = initial_pause_requested
|
||||
.then(|| TokioInstant::now() + self.tool_execution_policy.pause_safe_boundary_timeout);
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
while !batch.futures.is_empty() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -1904,7 +1934,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1966,8 +1996,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
// A pause observed before provider ownership starts leaves every call
|
||||
// NotStarted and therefore eligible for an explicit later retry.
|
||||
if self.try_paused() {
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
||||
@@ -2006,16 +2038,18 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
context,
|
||||
};
|
||||
|
||||
let pre_tool_action =
|
||||
self.interceptor
|
||||
.pre_tool_call(&mut info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
let pre_tool_action = match await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
@@ -2082,6 +2116,14 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after all policies and immediately before provider ownership.
|
||||
// No tool task exists yet: these calls remain proven NotStarted.
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Phase 2: Execute approved tools in parallel. FuturesUnordered yields
|
||||
// each terminal result as soon as that call completes instead of
|
||||
// holding fast siblings behind the slowest call in the batch.
|
||||
@@ -2158,7 +2200,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2190,7 +2232,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2271,7 +2313,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2299,7 +2341,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2340,8 +2382,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply post-execution policy, bound the model-visible payload, durably
|
||||
/// append one terminal ToolResult, and only then publish it to observers.
|
||||
/// Bound the model-visible payload, durably append one terminal ToolResult,
|
||||
/// publish it, then await interruptible post-execution policy.
|
||||
async fn finalize_and_commit_tool_result(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
@@ -2349,7 +2391,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
mut tool_result: ToolResult,
|
||||
execution_attempt_id: Option<&str>,
|
||||
turn_id: usize,
|
||||
invoke_post_hook: bool,
|
||||
post_policy_wait: PostToolPolicyWait,
|
||||
call_info_map: &HashMap<
|
||||
String,
|
||||
(
|
||||
@@ -2434,7 +2476,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
self.emit_tool_result(&tool_result);
|
||||
|
||||
if invoke_post_hook && let Some((tool_call, meta, tool, context)) = call_info {
|
||||
if !matches!(post_policy_wait, PostToolPolicyWait::Skip)
|
||||
&& let Some((tool_call, meta, tool, context)) = call_info
|
||||
{
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PostToolCall,
|
||||
Some(turn_id),
|
||||
@@ -2450,27 +2494,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
let pause_deadline = post_policy_wait.pause_deadline();
|
||||
let post_tool_action = tokio::select! {
|
||||
result = self.interceptor.post_tool_call(&info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PostToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %tool_call.id, "Paused while awaiting post-tool policy");
|
||||
}
|
||||
biased;
|
||||
// Poll the existing safe-boundary deadline even when the Pause
|
||||
// that established it was consumed by an outer policy gate.
|
||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %tool_call.id, "Cancelled while awaiting post-tool policy");
|
||||
}
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
result = await_tool_policy(
|
||||
self.interceptor.post_tool_call(&info),
|
||||
InterceptorPhase::PostToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
) => result?,
|
||||
};
|
||||
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
@@ -2508,7 +2545,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(&call_id),
|
||||
None,
|
||||
self.turn_count,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2970,22 +3007,29 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
)),
|
||||
0,
|
||||
);
|
||||
let assistant_turn_action = self
|
||||
.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
})
|
||||
.await;
|
||||
let assistant_turn_action = await_tool_policy(
|
||||
self.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
}),
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let assistant_turn_action = match assistant_turn_action {
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => {
|
||||
if let Some(batch) = early_tools.take() {
|
||||
self.drain_early_tool_batch(history, annotate, batch, false, true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
Err(error) => {
|
||||
let error = EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
error,
|
||||
));
|
||||
if let Some(batch) = early_tools.take()
|
||||
&& let Err(cleanup_error) = self
|
||||
.drain_early_tool_batch(history, annotate, batch, true, false)
|
||||
@@ -3551,7 +3595,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
//! keeping host-specific policy out of the Engine. Additional model input must go
|
||||
//! through history appends: inserting new facts only into an in-flight request
|
||||
//! would make its input impossible to reconstruct from stored history.
|
||||
//!
|
||||
//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause;
|
||||
//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so
|
||||
//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine
|
||||
//! drains owned tool attempts through their original fences. Terminal tool history and result
|
||||
//! publication precede post-tool policy; interruption cannot erase that result. Pause draining
|
||||
//! bounds post-tool waits by the existing safe-boundary deadline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -106,15 +106,23 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
|
||||
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
|
||||
///
|
||||
/// Reload and merge before writing to preserve fields if a concurrent
|
||||
/// Codex CLI / another process has already refreshed.
|
||||
/// Reload and merge before writing to preserve fields updated by a concurrent
|
||||
/// Codex CLI / another process. Account and refresh token must still match the
|
||||
/// request's input: merge unrelated fields, but return the current snapshot
|
||||
/// without writing if another login or token rotation is observed.
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
refresh_input: &AuthSnapshot,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
if current.account_id != refresh_input.account_id
|
||||
|| current.refresh_token != refresh_input.refresh_token
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
@@ -243,10 +251,16 @@ mod tests {
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let input = load(&path).await.unwrap();
|
||||
let updated = persist_refreshed(
|
||||
&path,
|
||||
&input,
|
||||
None,
|
||||
Some("new-acc".into()),
|
||||
Some("new-ref".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
assert_eq!(updated.refresh_token, "new-ref");
|
||||
// The unknown field agent_identity is preserved
|
||||
@@ -266,7 +280,8 @@ mod tests {
|
||||
);
|
||||
// Change the existing file to 644, then persist → check that it returns to 600
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
let input = load(&path).await.unwrap();
|
||||
persist_refreshed(&path, &input, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
|
||||
@@ -17,6 +17,8 @@ mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
#[cfg(test)]
|
||||
mod refresh_tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +42,9 @@ pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
refresh_limits: refresh::RefreshLimits,
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: Option<Arc<tokio::sync::Barrier>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
@@ -76,6 +81,9 @@ impl CodexAuthProvider {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
refresh_limits: refresh::RefreshLimits::default(),
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: None,
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
@@ -106,8 +114,13 @@ impl CodexAuthProvider {
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Reload immediately before refreshing and check that account_id matches (guarded reload).
|
||||
// If it differs, another process has already updated it → skip refreshing
|
||||
// 3. Reload immediately before refreshing (guarded reload).
|
||||
// Skip refresh if another process supplied fresh tokens or changed account_id.
|
||||
#[cfg(test)]
|
||||
if let Some(barrier) = &self.before_guarded_reload {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
@@ -124,12 +137,14 @@ impl CodexAuthProvider {
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
&self.refresh_limits,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Write back (persist_refreshed reloads and merges before writing)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
&pre_refresh,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
@@ -210,13 +225,18 @@ mod tests {
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
pub(super) fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
pub(super) fn write_auth(
|
||||
dir: &std::path::Path,
|
||||
exp: i64,
|
||||
fedramp: bool,
|
||||
refresh: &str,
|
||||
) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
|
||||
@@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Limits cover send, both response bodies, and decode, not auth file I/O.
|
||||
/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RefreshLimits {
|
||||
pub timeout: Duration,
|
||||
pub max_body_bytes: usize,
|
||||
#[cfg(test)]
|
||||
pub body_started: Option<std::sync::Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
impl Default for RefreshLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_body_bytes: 64 * 1024,
|
||||
#[cfg(test)]
|
||||
body_started: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
@@ -37,54 +56,94 @@ pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let deadline = tokio::time::Instant::now() + limits.timeout;
|
||||
let result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
request_refresh_bounded(client, endpoint, refresh_token, limits),
|
||||
)
|
||||
.await;
|
||||
// JSON decode is synchronous. The byte bound limits its work, and this
|
||||
// post-check prevents a decode that crosses the deadline from being persisted.
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"oauth_token_refresh timed out".into(),
|
||||
));
|
||||
}
|
||||
result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))?
|
||||
}
|
||||
|
||||
async fn request_refresh_bounded(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let mut response = client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
)))
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > limits.max_body_bytes as u64)
|
||||
{
|
||||
return Err(body_too_large());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(started) = &limits.body_started {
|
||||
started.notify_one();
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))?
|
||||
{
|
||||
// Content-Length can be absent or untrustworthy. Bound actual bytes too,
|
||||
// before copying a chunk into the accumulated body.
|
||||
if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) {
|
||||
return Err(body_too_large());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if status.is_success() {
|
||||
let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| {
|
||||
// Do not expose untrusted body values (which can contain secrets).
|
||||
CodexAuthError::RefreshTransient("decode response failed".into())
|
||||
})?;
|
||||
if tokens.access_token.as_deref().is_none_or(str::is_empty)
|
||||
|| tokens.id_token.as_deref().is_some_and(str::is_empty)
|
||||
|| tokens.refresh_token.as_deref().is_some_and(str::is_empty)
|
||||
{
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"incomplete token response".into(),
|
||||
));
|
||||
}
|
||||
Ok(tokens)
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes));
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
// Error bodies are consumed within the same limits but never echoed.
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"refresh endpoint returned {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
fn body_too_large() -> CodexAuthError {
|
||||
CodexAuthError::RefreshTransient("response body exceeds size limit".into())
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
@@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
@@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Real AuthProvider calls against owned auth files and a loopback HTTP peer.
|
||||
//! Gates place callers at response-body / reload boundaries; paused Tokio time
|
||||
//! advances the injected HTTP deadline only after headers have been received.
|
||||
use super::tests::{make_jwt, write_auth};
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration as StdDuration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
body: Vec<u8>,
|
||||
chunked: bool,
|
||||
gate: Option<Arc<Notify>>,
|
||||
truncate: bool,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
fn body(status: u16, body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
chunked: false,
|
||||
gate: None,
|
||||
truncate: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self {
|
||||
Self {
|
||||
gate: Some(gate.clone()),
|
||||
..Self::body(status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Stub {
|
||||
endpoint: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for Stub {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the server's JoinSet also aborts stalled connection handlers.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
async fn start(replies: Vec<Reply>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut handlers = JoinSet::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
handlers.spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let header_end = loop {
|
||||
let byte = stream.read_u8().await.unwrap();
|
||||
request.push(byte);
|
||||
if request.ends_with(b"\r\n\r\n") { break request.len(); }
|
||||
assert!(request.len() < 16 * 1024, "fixture request headers too large");
|
||||
};
|
||||
let headers = std::str::from_utf8(&request).unwrap();
|
||||
assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n"));
|
||||
let len: usize = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
}).unwrap();
|
||||
request.resize(header_end + len, 0);
|
||||
stream.read_exact(&mut request[header_end..]).await.unwrap();
|
||||
tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap();
|
||||
let framing = if reply.chunked {
|
||||
"Transfer-Encoding: chunked".to_string()
|
||||
} else {
|
||||
format!("Content-Length: {}", reply.body.len())
|
||||
};
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status
|
||||
).as_bytes()).await.unwrap();
|
||||
if let Some(gate) = reply.gate { gate.notified().await; }
|
||||
let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] };
|
||||
if reply.chunked {
|
||||
for chunk in body.chunks(512) {
|
||||
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await;
|
||||
let _ = stream.write_all(chunk).await;
|
||||
let _ = stream.write_all(b"\r\n").await;
|
||||
}
|
||||
let _ = stream.write_all(b"0\r\n\r\n").await;
|
||||
} else {
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = handlers.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
endpoint,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_body() -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})),
|
||||
"refresh_token": "fixture-rotated-secret",
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc<Notify>) -> Arc<CodexAuthProvider> {
|
||||
let mut provider = CodexAuthProvider::new(dir.to_path_buf())
|
||||
.with_refresh_endpoint(&stub.endpoint)
|
||||
.with_http_client(reqwest::Client::builder().no_proxy().build().unwrap());
|
||||
provider.refresh_limits = refresh::RefreshLimits {
|
||||
timeout: StdDuration::from_secs(10),
|
||||
max_body_bytes: 1024,
|
||||
body_started: Some(started.clone()),
|
||||
};
|
||||
Arc::new(provider)
|
||||
}
|
||||
|
||||
async fn bounded<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::time::timeout(StdDuration::from_secs(5), future)
|
||||
.await
|
||||
.expect("fixture/caller did not progress")
|
||||
}
|
||||
|
||||
fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) {
|
||||
let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret");
|
||||
assert_eq!(value["fixture_extra"], "preserve me");
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(name, _)| name == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
auth.1.to_str().unwrap(),
|
||||
format!(
|
||||
"Bearer {}",
|
||||
value["tokens"]["access_token"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stale_auth(dir: &std::path::Path) -> PathBuf {
|
||||
let path = write_auth(
|
||||
dir,
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"fixture-old-secret",
|
||||
);
|
||||
let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
value["fixture_extra"] = json!("preserve me");
|
||||
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() {
|
||||
for status in [200, 401, 503] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let gate = Arc::new(Notify::new());
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(status, b"unfinished".to_vec(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await; // provider has received headers and is consuming the body
|
||||
let request = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(request["refresh_token"], "fixture-old-secret");
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(provider.state.try_lock().is_err());
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"waiter must not refresh concurrently"
|
||||
);
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(StdDuration::from_secs(11)).await;
|
||||
let error = first.await.unwrap().unwrap_err();
|
||||
tokio::time::resume();
|
||||
assert!(error.to_string().contains("timed out"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(second).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
let retry = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(retry["refresh_token"], "fixture-old-secret");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() {
|
||||
let mut cases = vec![
|
||||
(Reply::body(200, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(200, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(401, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(Reply::body(503, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(),
|
||||
),
|
||||
"decode",
|
||||
),
|
||||
(Reply::body(200, b"{".to_vec()), "decode"),
|
||||
(Reply::body(200, b"{}".to_vec()), "incomplete"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(),
|
||||
),
|
||||
"incomplete",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(200, rotated_body())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(401, b"fixture-body-secret".to_vec())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply::body(401, b"fixture-body-secret".to_vec()),
|
||||
"codex login",
|
||||
),
|
||||
(Reply::body(503, b"fixture-body-secret".to_vec()), "503"),
|
||||
];
|
||||
for (reply, expected) in cases.drain(..) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let error = bounded(provider.headers()).await.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains(expected), "expected {expected}: {message}");
|
||||
assert!(!message.contains("fixture-body-secret"));
|
||||
assert!(!message.contains("fixture-old-secret"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(provider.headers()).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(200, rotated_body(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
first.abort();
|
||||
assert!(first.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert_rotated(&path, &bounded(second).await.unwrap());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
external["concurrent_extra"] = json!("merge me");
|
||||
std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap();
|
||||
gate.notify_one();
|
||||
let first_headers = bounded(first).await.unwrap().unwrap();
|
||||
let second_headers = bounded(second).await.unwrap();
|
||||
assert_eq!(first_headers, second_headers);
|
||||
assert_rotated(&path, &second_headers);
|
||||
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(persisted["concurrent_extra"], "merge me");
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"single-flight must make only one request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_switch_before_guarded_reload_skips_old_account_refresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let mut provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let mut stub = Stub::start(vec![]).await;
|
||||
provider.refresh_endpoint = stub.endpoint.clone();
|
||||
provider.before_guarded_reload = Some(barrier.clone());
|
||||
let task = tokio::spawn(async move { provider.headers().await });
|
||||
bounded(barrier.wait()).await;
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
barrier.wait().await;
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account")
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() {
|
||||
for account_changed in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let task = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
if account_changed {
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
} else {
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
}
|
||||
switched["tokens"]["access_token"] =
|
||||
json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200})));
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
gate.notify_one();
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let expected = format!(
|
||||
"Bearer {}",
|
||||
switched["tokens"]["access_token"].as_str().unwrap()
|
||||
);
|
||||
assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected));
|
||||
assert_eq!(bounded(provider.headers()).await.unwrap(), headers);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -2873,3 +2873,737 @@ async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning(
|
||||
assert!(matches!(next, EngineRunExit::Finished));
|
||||
}
|
||||
}
|
||||
|
||||
// The same policy barrier is exercised through the real response-complete,
|
||||
// provider fallback, interceptor fallback, and early-dispatch engine paths.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PolicyRoute {
|
||||
AfterResponse,
|
||||
ProviderFallback,
|
||||
InterceptorFallback,
|
||||
Early,
|
||||
}
|
||||
|
||||
impl PolicyRoute {
|
||||
fn completion_support(self) -> ToolCallCompletionSupport {
|
||||
match self {
|
||||
Self::ProviderFallback => ToolCallCompletionSupport::ResponseComplete,
|
||||
_ => ToolCallCompletionSupport::PerBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(self, engine: &mut Engine<ControlledStreamClient>) {
|
||||
engine.set_max_turns(Some(1));
|
||||
if matches!(self, Self::AfterResponse) {
|
||||
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotifyOnDrop(Arc<tokio::sync::Notify>);
|
||||
|
||||
impl Drop for NotifyOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PolicyBarrier {
|
||||
phase: InterceptorPhase,
|
||||
early_compatible: bool,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
invocations: Arc<AtomicUsize>,
|
||||
continues: Arc<AtomicUsize>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
// Enqueue from the policy's final poll, after select has polled signals.
|
||||
// This deterministically exercises the policy-completion/ownership race.
|
||||
signal_on_continue: Option<tokio::sync::mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl PolicyBarrier {
|
||||
fn new(phase: InterceptorPhase, route: PolicyRoute) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
early_compatible: !matches!(route, PolicyRoute::InterceptorFallback),
|
||||
entered: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
invocations: Arc::new(AtomicUsize::new(0)),
|
||||
continues: Arc::new(AtomicUsize::new(0)),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
signal_on_continue: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.invocations.fetch_add(1, Ordering::SeqCst);
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
if let Some(signal) = &self.signal_on_continue {
|
||||
signal.try_send(()).unwrap();
|
||||
}
|
||||
self.continues.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PolicyBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
self.early_compatible
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
if self.phase == InterceptorPhase::PreToolCall {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
if self.phase == InterceptorPhase::AssistantTurnEnd {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender<Result<Event, ClientError>>) {
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::usage(10, 3),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_policy_interruption(exit: &EngineRunExit, pause: bool) {
|
||||
if pause {
|
||||
assert!(matches!(exit, EngineRunExit::Paused), "{exit:?}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(
|
||||
exit,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
),
|
||||
"{exit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn call_and_result_count(history: &History) -> (usize, usize) {
|
||||
(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_complete_pending_policies_interrupt_before_release_and_preserve_not_started() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
] {
|
||||
for phase in [
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let policy = PolicyBarrier::new(phase, route);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
signal.send(()).await.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run).await
|
||||
.unwrap_or_else(|_| panic!("{route:?}/{phase:?}/pause={pause}: interruption waited for policy release"))
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: None,
|
||||
status: None | Some(agen::llm_client::ItemStatus::Completed),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
probe.release.notify_one(); // late permission cannot create a task
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
// Recreate both history and engine as a restore boundary. Only
|
||||
// proven NotStarted is allowed to run, with normal pre-tool policy.
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored =
|
||||
Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.set_max_turns(Some(1));
|
||||
restored.register_tool(tool.definition());
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_final_poll_signal_prevents_provider_ownership_even_with_continue() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
PolicyRoute::Early,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::PreToolCall, route);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
probe.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"{route:?}/pause={pause}: Continue bypassed queued interruption"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider owns the terminal channel. Cancel acknowledges receipt but does not
|
||||
// manufacture a result, so tests can release a real terminal output or deliver
|
||||
// it after the engine's confirmation deadline (without a live side effect).
|
||||
#[derive(Clone)]
|
||||
struct PolicyOwnedTool {
|
||||
starts: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
cancel_requested: Arc<tokio::sync::Notify>,
|
||||
terminal: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<ToolOutput>>>>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for PolicyOwnedTool {
|
||||
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
self.started.notify_one();
|
||||
Ok(self.terminal.lock().await.take().unwrap().await.unwrap())
|
||||
}
|
||||
async fn cancel(&self, _: &str) -> Result<(), ToolError> {
|
||||
self.cancel_requested.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_interrupts_drain_owned_early_attempt_and_fence_late_result_on_restore() {
|
||||
for pause in [false, true] {
|
||||
for late in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::ZERO,
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: if late {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
},
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let tool = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let tool = tool.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("policy_tool")
|
||||
.input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(tool.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
assert_eq!(
|
||||
commit_probe.load(Ordering::SeqCst),
|
||||
1,
|
||||
"commit before publication"
|
||||
);
|
||||
publish_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
// Wait for ownership before completing the assistant response.
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.started.notified())
|
||||
.await
|
||||
.expect("early provider started");
|
||||
tx.send(Ok(Event::usage(10, 3))).unwrap();
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end policy pending");
|
||||
signal.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.cancel_requested.notified())
|
||||
.await
|
||||
.expect("owned provider cancellation progresses before policy release");
|
||||
let terminal_tx = if late {
|
||||
Some(terminal_tx)
|
||||
} else {
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed after interruption".to_string()))
|
||||
.unwrap();
|
||||
None
|
||||
};
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("terminalization does not await turn-end policy")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: Some(_),
|
||||
status: Some(agen::llm_client::ItemStatus::InProgress),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(history.items().any(|item| matches!(item, Item::ToolResult { disposition, .. } if *disposition == if late { ToolResultDisposition::OutcomeUnknown } else { ToolResultDisposition::Success })));
|
||||
if let Some(terminal_tx) = terminal_tx {
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.retired.notified())
|
||||
.await
|
||||
.expect("forced-close retired the owned receiver");
|
||||
// Force-close retires the receiver; a provider's late completion
|
||||
// cannot reenter history or replace OutcomeUnknown.
|
||||
assert!(
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("late completion".to_string()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
let original_items = history.items().cloned().collect::<Vec<_>>();
|
||||
let mut resumed_history = history.clone();
|
||||
let mut resumed_engine = output.engine;
|
||||
let _ = resumed_engine.resume(&mut resumed_history).await;
|
||||
assert_eq!(
|
||||
tool.starts.load(Ordering::SeqCst),
|
||||
1,
|
||||
"same-engine resume cannot rerun a terminal attempt"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&resumed_history), (1, 1));
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let restored_client = MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]);
|
||||
let request_probe = restored_client.clone();
|
||||
let mut restored = Engine::new(restored_client);
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
let requests = request_probe.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let replay = requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolCall { call_id, .. } => Some(format!("call:{call_id}")),
|
||||
Item::ToolResult { call_id, .. } => Some(format!("result:{call_id}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(replay, ["call:policy_call", "result:policy_call"]);
|
||||
assert_eq!(tool.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.take(original_items.len())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
original_items,
|
||||
"restore must not rewrite original history"
|
||||
);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_pause_preserves_natural_safe_boundary_without_cancelling_started_tool() {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_secs(2),
|
||||
..ToolExecutionPolicy::default()
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SafePauseTool::new();
|
||||
// Use this tool's actual public name in the stream fixture.
|
||||
engine.register_tool(tool.definition());
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "safe_pause"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.retired.notified())
|
||||
.await
|
||||
.expect("pending policy interrupted before tool release");
|
||||
tool.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("safe boundary collected")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_final_poll_signal_wins_over_finish_without_tools() {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
engine.run(&mut history, "run").await
|
||||
});
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
probe.release.notify_one();
|
||||
let output = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_pause_deadline_reclaims_started_sibling_while_post_policy_is_pending() {
|
||||
struct TurnAndPostBarrier {
|
||||
turn: PolicyBarrier,
|
||||
post: PolicyBarrier,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interceptor for TurnAndPostBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
self.turn.wait().await;
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_: &ToolResultInfo<'_>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.post.wait().await;
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(500),
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: Duration::from_secs(2),
|
||||
});
|
||||
let turn = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let post = PolicyBarrier::new(InterceptorPhase::PostToolCall, PolicyRoute::Early);
|
||||
engine.set_interceptor(TurnAndPostBarrier {
|
||||
turn: turn.clone(),
|
||||
post: post.clone(),
|
||||
});
|
||||
let completed = BarrierTool::new("completed_before_pause");
|
||||
engine.register_tool(completed.definition());
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let sibling = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let sibling = sibling.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("pending_sibling").input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(sibling.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
let n = publish_probe.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
assert_eq!(commit_probe.load(Ordering::SeqCst), n);
|
||||
});
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for (index, id, name) in [
|
||||
(0, "policy_call", "completed_before_pause"),
|
||||
(1, "sibling_call", "pending_sibling"),
|
||||
] {
|
||||
for event in [
|
||||
Event::tool_use_start(index, id, name),
|
||||
Event::tool_input_delta(index, "{}"),
|
||||
Event::tool_use_stop(index),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), completed.started.notified())
|
||||
.await
|
||||
.expect("first provider owned");
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.started.notified())
|
||||
.await
|
||||
.expect("sibling provider owned");
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.entered.notified())
|
||||
.await
|
||||
.expect("turn-end pending");
|
||||
pause.send(()).await.unwrap(); // only one interruption signal
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.retired.notified())
|
||||
.await
|
||||
.expect("turn-end interrupted");
|
||||
completed.release.notify_one();
|
||||
tokio::time::timeout(Duration::from_secs(2), post.entered.notified())
|
||||
.await
|
||||
.expect("completed result's post policy pending");
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.cancel_requested.notified()).await.expect("original Pause deadline must reach sibling cancellation without releasing either policy");
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed sibling terminal".to_string()))
|
||||
.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("all owned tools reclaimed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.continues.load(Ordering::SeqCst), 0);
|
||||
turn.release.notify_one();
|
||||
post.release.notify_one();
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored = Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.register_tool(completed.definition());
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use ticket::{
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work";
|
||||
|
||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||
///
|
||||
@@ -91,6 +92,56 @@ impl BackendWorkspaceProductClient {
|
||||
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
|
||||
}
|
||||
|
||||
pub fn list_workspace_tags(
|
||||
&self,
|
||||
request: &ticket::TagListRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_list(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagListRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: &ticket::WorkspaceTagMutation,
|
||||
) -> Result<ticket::WorkspaceTag, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_mutate(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagMutationRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_tags(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
request: &ticket::TicketTagSetRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tickets(
|
||||
&self,
|
||||
query: &TicketListQuery,
|
||||
@@ -155,76 +206,24 @@ 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_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
_id: &TicketIdOrSlug,
|
||||
_change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let change = self.decision_from_state_change(id, change)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn close_ticket(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
_id: &TicketIdOrSlug,
|
||||
_resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(request),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn add_ticket_relation(
|
||||
@@ -602,6 +601,31 @@ impl BackendWorkspaceProductClient {
|
||||
}
|
||||
|
||||
impl TicketBackend for BackendWorkspaceProductClient {
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::list_workspace_tags(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> ticket::Result<ticket::WorkspaceTag> {
|
||||
BackendWorkspaceProductClient::mutate_workspace_tag(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let from = from.to_string();
|
||||
@@ -664,24 +688,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
|
||||
fn add_state_changed(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
_id: TicketIdOrSlug,
|
||||
_change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_change_add(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
Err(TicketError::Conflict(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn add_intake_summary(
|
||||
@@ -703,59 +715,31 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_state_field(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
if field != "state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"unsupported Ticket state field".into(),
|
||||
));
|
||||
}
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_field_set(
|
||||
workspace_id,
|
||||
id,
|
||||
field,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_workflow_state(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.set_ticket_workflow_state(&id, &change)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
fn update_case(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: ticket::TicketStateUpdate,
|
||||
request: ticket::TicketCaseUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket.to_owned();
|
||||
// Actor identity belongs to authenticated Backend authority, not the caller.
|
||||
let request = server_api::TicketCaseUpdateRequest {
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
expected_closure_operation_key: request.expected_closure_operation_key,
|
||||
operation_key: request.operation_key,
|
||||
action: request.action,
|
||||
explanation: request.explanation,
|
||||
references: request.references,
|
||||
};
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_update(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateUpdateRequest(request),
|
||||
)
|
||||
.ticket_case_update(workspace_id.clone(), id.clone(), request)
|
||||
.await?;
|
||||
// The case endpoint returns only a summary. Fetch the authoritative
|
||||
// full record rather than fabricating body, events, or case history.
|
||||
client
|
||||
.ticket_record_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
@@ -795,11 +779,6 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
||||
self.close_ticket(&id, &resolution)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
@@ -941,6 +920,33 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4_096];
|
||||
loop {
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
assert_ne!(bytes, 0, "incomplete HTTP request");
|
||||
request.extend_from_slice(&buffer[..bytes]);
|
||||
if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
|
||||
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
return String::from_utf8(request).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_response_server(
|
||||
status: &str,
|
||||
body: &str,
|
||||
@@ -952,11 +958,7 @@ mod tests {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 8_192];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -980,11 +982,7 @@ mod tests {
|
||||
let handle = thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 16_384];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -997,116 +995,357 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_content_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();
|
||||
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let id = TicketIdOrSlug::Query("T-739".into());
|
||||
let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation");
|
||||
let errors = [
|
||||
client
|
||||
.set_ticket_workflow_state(&id, &change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
client
|
||||
.close_ticket(&id, &MarkdownText::new("Withdrawn"))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
TicketBackend::add_state_changed(&client, id, change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
];
|
||||
for error in errors {
|
||||
assert!(
|
||||
error.contains("unsupported legacy Ticket state/close operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("TicketBackend::update_case"), "{error}");
|
||||
assert!(error.contains("caller-supplied"), "{error}");
|
||||
assert!(error.contains("CAS"), "{error}");
|
||||
}
|
||||
assert_eq!(
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
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")
|
||||
fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() {
|
||||
for action in [
|
||||
serde_json::json!({"action": "close", "reason": "completed"}),
|
||||
serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}),
|
||||
serde_json::json!({"action": "correct_closure", "reason": "duplicate"}),
|
||||
serde_json::json!({"action": "reopen"}),
|
||||
] {
|
||||
// SQLite only supplies hermetic response fixtures, never a client fallback.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let mut input = NewTicket::new("Full case record");
|
||||
input.body = MarkdownText::new("Full body absent from the endpoint summary");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "classify-create".into(),
|
||||
action: ticket::WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
})
|
||||
.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")
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
reference.id.clone().into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "classify-set".into(),
|
||||
expected_tag_ids: Vec::new(),
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.close_ticket(
|
||||
&reference.id.into(),
|
||||
&MarkdownText::new("The request was withdrawn"),
|
||||
let mut snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
if action["action"] != "close" {
|
||||
snapshot = backend
|
||||
.update_case(
|
||||
&reference.id,
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state: ticket::TicketState::Open,
|
||||
expected_closure_operation_key: None,
|
||||
operation_key: "initial-close".into(),
|
||||
action: ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Initial judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("authenticated-actor".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let expected_request = serde_json::json!({
|
||||
"expected_content_digest": ticket::ticket_content_digest(&snapshot),
|
||||
"expected_state": snapshot.meta.workflow_state.as_str(),
|
||||
"expected_closure_operation_key": snapshot.closure_operation_key,
|
||||
"operation_key": "caller-case-key",
|
||||
"action": action,
|
||||
"explanation": "Caller judgment, not work progress",
|
||||
"references": [{"kind": "ticket", "target": "T-738"}],
|
||||
});
|
||||
let mut domain_request = expected_request.clone();
|
||||
domain_request["author"] = serde_json::json!("caller-cannot-impersonate");
|
||||
let operation =
|
||||
serde_json::from_value::<ticket::TicketBackendOperation>(serde_json::json!({
|
||||
"operation": "update_case",
|
||||
"ticket": reference.id,
|
||||
"request": domain_request,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut fixture_request: ticket::TicketCaseUpdate =
|
||||
serde_json::from_value(expected_request.clone()).unwrap();
|
||||
fixture_request.author = Some("authenticated-actor".into());
|
||||
let expected = backend.update_case(&reference.id, fixture_request).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let full_record = serde_json::to_string(&expected).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.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_content_digest"],
|
||||
ticket::ticket_content_digest(&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();
|
||||
let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap();
|
||||
let ticket::TicketBackendOperationResult::Ticket(actual) = result else {
|
||||
panic!("case operation must return a full Ticket");
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.tags, vec![tag]);
|
||||
assert!(!actual.case_history.is_empty());
|
||||
assert_eq!(actual.closure_operation_key, expected.closure_operation_key);
|
||||
let post = requests.recv().unwrap();
|
||||
assert!(
|
||||
post.starts_with(&format!(
|
||||
"POST /workspaces/workspace-a/tickets/{}/case ",
|
||||
reference.id,
|
||||
)),
|
||||
"{post}"
|
||||
);
|
||||
assert!(
|
||||
post.contains("authorization: Bearer test-token\r\n"),
|
||||
"{post}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, expected_request);
|
||||
assert!(body.get("author").is_none());
|
||||
assert!(requests.recv().unwrap().starts_with(&format!(
|
||||
"GET /api/w/workspace-a/tickets/{}/record ",
|
||||
reference.id,
|
||||
)));
|
||||
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 content","diagnostics":[]}"#,
|
||||
);
|
||||
fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() {
|
||||
let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false});
|
||||
let single = tag.to_string();
|
||||
let list = serde_json::json!([tag]).to_string();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &list),
|
||||
("200 OK", &single),
|
||||
("200 OK", &list),
|
||||
]);
|
||||
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_content_digest: "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();
|
||||
let listed = client
|
||||
.list_workspace_tags(&ticket::TagListRequest {
|
||||
after: Some("tag-0".into()),
|
||||
limit: Some(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(listed[0].id, "tag-a");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
|
||||
request.starts_with("GET /api/w/workspace-a/ticket-tags?"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("after=tag-0") && request.contains("limit=7"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.to_lowercase()
|
||||
.contains("authorization: bearer test-token")
|
||||
);
|
||||
let mutation = ticket::WorkspaceTagMutation {
|
||||
operation_key: "rename-1".into(),
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: "tag-a".into(),
|
||||
expected_last_operation_key: "create-1".into(),
|
||||
name: "New".into(),
|
||||
},
|
||||
};
|
||||
TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap();
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/ticket-tags "),
|
||||
"{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_content_digest"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
assert_eq!(body, serde_json::json!(mutation));
|
||||
let set = ticket::TicketTagSetRequest {
|
||||
operation_key: "set-1".into(),
|
||||
expected_tag_ids: vec!["tag-old".into()],
|
||||
tag_ids: vec!["tag-a".into(), "tag-b".into()],
|
||||
};
|
||||
let receipt =
|
||||
TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone())
|
||||
.unwrap();
|
||||
assert_eq!(receipt[0].name, "Ready");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!(set));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_returns_backend_denials_without_local_fallback() {
|
||||
for status in ["403 Forbidden", "409 Conflict"] {
|
||||
let (base_url, requests, handle) = one_response_server(
|
||||
status,
|
||||
r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = client.set_ticket_tags(
|
||||
&TicketIdOrSlug::from("T-740"),
|
||||
&ticket::TicketTagSetRequest {
|
||||
operation_key: "retry-key".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["tag-a".into()],
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
requests
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn case_request() -> ticket::TicketCaseUpdate {
|
||||
ticket::TicketCaseUpdate {
|
||||
operation_key: "caller-case-key".into(),
|
||||
expected_content_digest: "caller-content-digest".into(),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: Some("last-close-key".into()),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Caller reopening judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_update_rejection_preserves_caller_cas_without_local_fallback() {
|
||||
for (status, body, expected_error) in [
|
||||
(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
"409",
|
||||
),
|
||||
(
|
||||
"403 Forbidden",
|
||||
r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#,
|
||||
"403",
|
||||
),
|
||||
] {
|
||||
let (base_url, requests, handle) = one_response_server(status, body);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{error}; response {status}: {body}"
|
||||
);
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["expected_content_digest"], "caller-content-digest");
|
||||
assert_eq!(body["expected_state"], "closed");
|
||||
assert_eq!(body["expected_closure_operation_key"], "last-close-key");
|
||||
assert_eq!(body["operation_key"], "caller-case-key");
|
||||
assert_eq!(body["action"], serde_json::json!({"action": "reopen"}));
|
||||
assert!(!error.to_string().contains("test-token"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Summary only")).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &summary),
|
||||
(
|
||||
"404 Not Found",
|
||||
r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err();
|
||||
assert!(error.to_string().contains("404"), "{error}");
|
||||
assert!(requests.recv().unwrap().starts_with("POST /workspaces/"));
|
||||
assert!(requests.recv().unwrap().starts_with("GET /api/w/"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
@@ -1414,12 +1653,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery {
|
||||
state: TicketStateSelector::States(
|
||||
[TicketListState::Ready, TicketListState::InProgress]
|
||||
[TicketListState::Open, TicketListState::Closed]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
"ready,inprogress"
|
||||
"open,closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1338,7 +1338,7 @@ mod tests {
|
||||
|
||||
assert_eq!(manifest.engine.instruction, "role.ticket_worker");
|
||||
// enabled supplies ShowTicket; thread supplies TicketComment,
|
||||
// TicketWorkflowState and CompleteTicket without broad workflow access.
|
||||
// TicketCaseUpdate without broad workflow access.
|
||||
let feature = &manifest.feature;
|
||||
assert!(feature.ticket.enabled);
|
||||
assert!(feature.ticket.thread);
|
||||
|
||||
+108
-25
@@ -11,6 +11,9 @@
|
||||
//! accepted only after the initialize/initialized handshake. Shutdown closes stdin,
|
||||
//! waits for exit within a deadline, and kills/waits to reap the process if needed.
|
||||
//! Startup failures also trigger cleanup; killing on Drop is a last resort.
|
||||
//! A request shares one deadline across stdin ownership, write/flush, and matching response;
|
||||
//! synchronous result decode is outside that wait. Interrupted write/flush closes stdin.
|
||||
//! After a fully flushed frame, a missing response leaves execution unknown, not retry-safe.
|
||||
//!
|
||||
//! This module preserves Tool/resource/prompt wire values. `worker::feature::mcp`
|
||||
//! owns Yoi naming, schema acceptance, model output limits, and Feature registration.
|
||||
@@ -28,11 +31,11 @@ use secrets::SecretStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStderr, ChildStdout, Command};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout};
|
||||
use tokio::time::{Instant, timeout, timeout_at};
|
||||
|
||||
const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const CLIENT_NAME: &str = "yoi";
|
||||
@@ -48,8 +51,11 @@ pub struct McpStdioLimits {
|
||||
pub max_diagnostic_lines: usize,
|
||||
pub max_protocol_bytes: usize,
|
||||
pub startup_timeout: Duration,
|
||||
/// One budget for stdin ownership, frame write/flush, and response wait.
|
||||
pub request_timeout: Duration,
|
||||
/// Shared budget for stdin ownership/close and graceful process exit.
|
||||
pub shutdown_timeout: Duration,
|
||||
/// Budget for each fallback wait: terminate, then forced kill/reap.
|
||||
pub kill_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -587,6 +593,30 @@ pub fn resolve_stdio_server(
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
// The mutex orders entire frames, including replies emitted by the stdout reader.
|
||||
type SharedStdin = Arc<Mutex<Option<Box<dyn AsyncWrite + Send + Unpin>>>>;
|
||||
|
||||
/// Last boundary reached by a failed request. A flushed frame does not prove
|
||||
/// that the server executed the method; callers must not blindly retry it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpRequestStage {
|
||||
WaitingForStdin,
|
||||
WritingFrame,
|
||||
FlushingFrame,
|
||||
AwaitingResponse,
|
||||
}
|
||||
|
||||
impl fmt::Display for McpRequestStage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::WaitingForStdin => "stdin ownership (method not sent)",
|
||||
Self::WritingFrame => "frame write (delivery unknown; stdin closed)",
|
||||
Self::FlushingFrame => "frame flush (delivery unknown; stdin closed)",
|
||||
Self::AwaitingResponse => "response (frame flushed; execution unknown; do not retry)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A running initialized stdio MCP client.
|
||||
pub struct McpStdioClient {
|
||||
server_name: String,
|
||||
@@ -594,7 +624,7 @@ pub struct McpStdioClient {
|
||||
redactor: Redactor,
|
||||
diagnostics: Arc<Mutex<BoundedDiagnostics>>,
|
||||
list_changes: Arc<Mutex<BoundedListChanged>>,
|
||||
stdin: Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: SharedStdin,
|
||||
child: Option<Child>,
|
||||
responses: mpsc::Receiver<ReaderEvent>,
|
||||
reader_task: JoinHandle<()>,
|
||||
@@ -680,7 +710,7 @@ impl McpStdioClient {
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdin = Arc::new(Mutex::new(Some(stdin)));
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(stdin))));
|
||||
let diagnostics = Arc::new(Mutex::new(BoundedDiagnostics::new(
|
||||
spec.name.clone(),
|
||||
limits.max_diagnostic_lines,
|
||||
@@ -764,6 +794,12 @@ impl McpStdioClient {
|
||||
/// The caller is responsible for applying Yoi tool permissions before this
|
||||
/// method is reached and for bounding/serializing the untrusted result before
|
||||
/// it is exposed to model-visible tool history.
|
||||
///
|
||||
/// Timeout or cancellation after writing begins can leave execution unknown;
|
||||
/// this method never retries. Interrupted write/flush closes stdin, and the
|
||||
/// caller must shut down the process rather than reuse that connection. A
|
||||
/// fully flushed frame permits later *different* requests with distinct IDs,
|
||||
/// but is not permission to replay this call when its response is missing.
|
||||
pub async fn call_tool(
|
||||
&mut self,
|
||||
request: CallToolRequest,
|
||||
@@ -919,23 +955,35 @@ impl McpStdioClient {
|
||||
method,
|
||||
params,
|
||||
};
|
||||
self.write_protocol(phase, &request).await?;
|
||||
let response = match timeout(
|
||||
self.limits.request_timeout,
|
||||
self.wait_for_response(id, phase),
|
||||
)
|
||||
if self.shutdown_started {
|
||||
return Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
phase,
|
||||
McpErrorKind::Io("client shutdown has started".into()),
|
||||
));
|
||||
}
|
||||
let deadline = Instant::now() + self.limits.request_timeout;
|
||||
let mut stage = McpRequestStage::WaitingForStdin;
|
||||
// One budget covers ownership, write, flush, and the matching response.
|
||||
let response = match timeout_at(deadline, async {
|
||||
self.write_protocol(phase, &request, &mut stage).await?;
|
||||
stage = McpRequestStage::AwaitingResponse;
|
||||
self.wait_for_response(id, phase).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(err)) => return Err(err.with_request_stage(stage)),
|
||||
Err(_) => {
|
||||
return Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
phase,
|
||||
McpErrorKind::Timeout {
|
||||
operation: method.to_owned(),
|
||||
operation: self.redactor.redact(method),
|
||||
elapsed: self.limits.request_timeout,
|
||||
},
|
||||
)
|
||||
.with_request_stage(stage)
|
||||
.with_diagnostics(self.snapshot_diagnostics().await));
|
||||
}
|
||||
};
|
||||
@@ -1000,6 +1048,7 @@ impl McpStdioClient {
|
||||
method: &str,
|
||||
params: T,
|
||||
) -> Result<(), McpClientError> {
|
||||
let mut stage = McpRequestStage::WaitingForStdin;
|
||||
self.write_protocol(
|
||||
phase,
|
||||
&ClientNotification {
|
||||
@@ -1007,6 +1056,7 @@ impl McpStdioClient {
|
||||
method,
|
||||
params,
|
||||
},
|
||||
&mut stage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1015,6 +1065,7 @@ impl McpStdioClient {
|
||||
&mut self,
|
||||
phase: McpPhase,
|
||||
value: &T,
|
||||
stage: &mut McpRequestStage,
|
||||
) -> Result<(), McpClientError> {
|
||||
write_json_line(
|
||||
&self.server_name,
|
||||
@@ -1023,6 +1074,7 @@ impl McpStdioClient {
|
||||
value,
|
||||
self.limits.max_protocol_bytes,
|
||||
&self.redactor,
|
||||
stage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1030,15 +1082,23 @@ impl McpStdioClient {
|
||||
/// Close stdin and wait for process exit, falling back to terminate and kill.
|
||||
pub async fn shutdown(&mut self) -> Result<ShutdownReport, McpClientError> {
|
||||
self.shutdown_started = true;
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
stdin.take();
|
||||
}
|
||||
let deadline = Instant::now() + self.limits.shutdown_timeout;
|
||||
// Ownership and EOF share one graceful budget. A reader reply may own
|
||||
// stdin indefinitely; its lock is never required to terminate/kill.
|
||||
let closed = timeout_at(deadline, async {
|
||||
self.stdin.lock().await.take();
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let mut child = match self.child.take() {
|
||||
Some(child) => child,
|
||||
None => return Ok(ShutdownReport::already_finished()),
|
||||
};
|
||||
if !closed {
|
||||
self.reader_task.abort();
|
||||
return self.terminate_then_kill(child).await;
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
self.reader_task.abort();
|
||||
@@ -1050,7 +1110,7 @@ impl McpStdioClient {
|
||||
});
|
||||
}
|
||||
|
||||
match timeout(self.limits.shutdown_timeout, child.wait()).await {
|
||||
match timeout_at(deadline, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
@@ -1212,12 +1272,13 @@ pub struct McpDiagnostics {
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}")]
|
||||
#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}{stage_detail}", stage_detail = request_stage.map(|stage| format!("; {stage}")).unwrap_or_default())]
|
||||
pub struct McpClientError {
|
||||
pub server_name: String,
|
||||
pub phase: McpPhase,
|
||||
pub kind: McpErrorKind,
|
||||
diagnostics: Option<McpDiagnostics>,
|
||||
request_stage: Option<McpRequestStage>,
|
||||
}
|
||||
|
||||
impl McpClientError {
|
||||
@@ -1227,6 +1288,7 @@ impl McpClientError {
|
||||
phase,
|
||||
kind,
|
||||
diagnostics: None,
|
||||
request_stage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,6 +1302,15 @@ impl McpClientError {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_request_stage(mut self, stage: McpRequestStage) -> Self {
|
||||
self.request_stage = Some(stage);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn request_stage(&self) -> Option<McpRequestStage> {
|
||||
self.request_stage
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self) -> Option<&McpDiagnostics> {
|
||||
self.diagnostics.as_ref()
|
||||
}
|
||||
@@ -1328,7 +1399,7 @@ struct ErrorObject<'a> {
|
||||
fn spawn_stdout_reader(
|
||||
server_name: String,
|
||||
stdout: ChildStdout,
|
||||
stdin: Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: SharedStdin,
|
||||
tx: mpsc::Sender<ReaderEvent>,
|
||||
limits: McpStdioLimits,
|
||||
redactor: Redactor,
|
||||
@@ -1385,7 +1456,7 @@ fn spawn_stdout_reader(
|
||||
|
||||
async fn handle_incoming_message(
|
||||
server_name: &str,
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: &SharedStdin,
|
||||
tx: &mpsc::Sender<ReaderEvent>,
|
||||
limits: &McpStdioLimits,
|
||||
redactor: &Redactor,
|
||||
@@ -1409,6 +1480,7 @@ async fn handle_incoming_message(
|
||||
&response,
|
||||
limits.max_protocol_bytes,
|
||||
redactor,
|
||||
&mut McpRequestStage::WaitingForStdin,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1501,10 +1573,11 @@ fn spawn_stderr_reader(
|
||||
async fn write_json_line<T: Serialize>(
|
||||
server_name: &str,
|
||||
phase: McpPhase,
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: &SharedStdin,
|
||||
value: &T,
|
||||
max_protocol_bytes: usize,
|
||||
redactor: &Redactor,
|
||||
stage: &mut McpRequestStage,
|
||||
) -> Result<(), McpClientError> {
|
||||
let mut bytes = serde_json::to_vec(value).map_err(|err| {
|
||||
McpClientError::new(
|
||||
@@ -1524,27 +1597,34 @@ async fn write_json_line<T: Serialize>(
|
||||
}
|
||||
bytes.push(b'\n');
|
||||
let mut guard = stdin.lock().await;
|
||||
let Some(stdin) = guard.as_mut() else {
|
||||
let Some(mut writer) = guard.take() else {
|
||||
return Err(McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io("child stdin is closed".into()),
|
||||
));
|
||||
};
|
||||
stdin.write_all(&bytes).await.map_err(|err| {
|
||||
// Keep ownership out of the slot across both awaits. Timeout/cancel or
|
||||
// I/O error drops the writer, closing the connection instead of allowing
|
||||
// the next frame to append to an unverified partial JSON line.
|
||||
*stage = McpRequestStage::WritingFrame;
|
||||
writer.write_all(&bytes).await.map_err(|err| {
|
||||
McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io(redactor.redact(&err.to_string())),
|
||||
)
|
||||
})?;
|
||||
stdin.flush().await.map_err(|err| {
|
||||
*stage = McpRequestStage::FlushingFrame;
|
||||
writer.flush().await.map_err(|err| {
|
||||
McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io(redactor.redact(&err.to_string())),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
*guard = Some(writer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_protocol_line<R: AsyncRead + Unpin>(
|
||||
@@ -1701,3 +1781,6 @@ fn send_terminate(child: &mut Child) -> Result<(), ()> {
|
||||
fn send_terminate(child: &mut Child) -> Result<(), ()> {
|
||||
child.start_kill().map_err(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deadline_tests;
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Module-boundary fixtures exercise the same writer/request path as ChildStdin.
|
||||
use super::*;
|
||||
use std::future::{Future, poll_fn};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Stall {
|
||||
PartialWrite,
|
||||
Flush,
|
||||
Response,
|
||||
}
|
||||
|
||||
struct ControlledWriter {
|
||||
stall: Stall,
|
||||
bytes: Arc<StdMutex<Vec<u8>>>,
|
||||
reached: Option<mpsc::UnboundedSender<()>>,
|
||||
dropped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ControlledWriter {
|
||||
fn signal(&mut self) {
|
||||
if let Some(tx) = self.reached.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for ControlledWriter {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
if matches!(self.stall, Stall::PartialWrite) {
|
||||
if self.bytes.lock().unwrap().is_empty() {
|
||||
self.bytes.lock().unwrap().extend_from_slice(&buf[..8]);
|
||||
return Poll::Ready(Ok(8));
|
||||
}
|
||||
self.signal();
|
||||
return Poll::Pending;
|
||||
}
|
||||
self.bytes.lock().unwrap().extend_from_slice(buf);
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
self.signal();
|
||||
if matches!(self.stall, Stall::Flush) {
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlledWriter {
|
||||
fn drop(&mut self) {
|
||||
self.dropped.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_client(stdin: SharedStdin) -> (McpStdioClient, mpsc::Sender<ReaderEvent>) {
|
||||
let (tx, responses) = mpsc::channel(16);
|
||||
let redactor = Redactor::new(vec!["fixture-secret".into()]);
|
||||
let client = McpStdioClient {
|
||||
server_name: "deadline-fixture".into(),
|
||||
limits: McpStdioLimits {
|
||||
request_timeout: Duration::from_millis(100),
|
||||
..Default::default()
|
||||
},
|
||||
diagnostics: Arc::new(Mutex::new(BoundedDiagnostics::new(
|
||||
"deadline-fixture".into(),
|
||||
2,
|
||||
redactor.clone(),
|
||||
))),
|
||||
redactor,
|
||||
list_changes: Arc::new(Mutex::new(BoundedListChanged::new(
|
||||
"deadline-fixture".into(),
|
||||
))),
|
||||
stdin,
|
||||
child: None,
|
||||
responses,
|
||||
reader_task: tokio::spawn(std::future::pending()),
|
||||
stderr_task: tokio::spawn(std::future::pending()),
|
||||
next_id: 1,
|
||||
initialized: None,
|
||||
shutdown_started: false,
|
||||
};
|
||||
(client, tx)
|
||||
}
|
||||
|
||||
async fn assert_pending<F: Future>(mut future: Pin<&mut F>) {
|
||||
poll_fn(|cx| match future.as_mut().poll(cx) {
|
||||
Poll::Pending => Poll::Ready(()),
|
||||
Poll::Ready(_) => panic!("fixture boundary unexpectedly completed"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdin_ownership_timeout_and_cancel_do_not_send_a_method() {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall: Stall::Response,
|
||||
bytes: bytes.clone(),
|
||||
reached: None,
|
||||
dropped: dropped.clone(),
|
||||
}))));
|
||||
let guard = stdin.lock().await;
|
||||
let (mut client, _tx) = fixture_client(stdin.clone());
|
||||
let mut request =
|
||||
Box::pin(client.request::<Value>(McpPhase::Running, "tools/call", json!({})));
|
||||
assert_pending(request.as_mut()).await;
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err.kind, McpErrorKind::Timeout { .. }));
|
||||
assert_eq!(err.request_stage(), Some(McpRequestStage::WaitingForStdin));
|
||||
assert!(err.to_string().contains("method not sent"));
|
||||
}
|
||||
drop(request);
|
||||
assert!(bytes.lock().unwrap().is_empty());
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
guard.is_some(),
|
||||
"cancel before ownership must preserve the stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_write_or_flush_closes_stdin_before_the_next_frame() {
|
||||
for (stall, stage) in [
|
||||
(Stall::PartialWrite, McpRequestStage::WritingFrame),
|
||||
(Stall::Flush, McpRequestStage::FlushingFrame),
|
||||
] {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall,
|
||||
bytes: bytes.clone(),
|
||||
reached: Some(reached),
|
||||
dropped: dropped.clone(),
|
||||
}))));
|
||||
let (mut client, _tx) = fixture_client(stdin.clone());
|
||||
let mut request = Box::pin(client.request::<Value>(
|
||||
McpPhase::Running,
|
||||
"tools/call",
|
||||
json!({"secret": "fixture-secret"}),
|
||||
));
|
||||
// Poll all the way to the controlled Pending, then observe its channel.
|
||||
assert_pending(request.as_mut()).await;
|
||||
timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err.kind, McpErrorKind::Timeout { .. }));
|
||||
assert_eq!(err.request_stage(), Some(stage));
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("delivery unknown; stdin closed"));
|
||||
assert!(!message.contains("fixture-secret"));
|
||||
}
|
||||
drop(request);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(stdin.lock().await.is_none());
|
||||
let before = bytes.lock().unwrap().clone();
|
||||
if matches!(stall, Stall::PartialWrite) {
|
||||
assert!(!before.contains(&b'\n'));
|
||||
} else {
|
||||
assert_eq!(before.last(), Some(&b'\n'));
|
||||
}
|
||||
let err = client
|
||||
.request::<Value>(McpPhase::Running, "next-method", json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("stdin is closed"));
|
||||
assert_eq!(
|
||||
*bytes.lock().unwrap(),
|
||||
before,
|
||||
"no next frame can append after interruption"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flushed_call_timeout_or_cancel_is_not_replayed_and_late_response_is_not_mixed() {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall: Stall::Response,
|
||||
bytes: bytes.clone(),
|
||||
reached: Some(reached),
|
||||
dropped: Arc::new(AtomicBool::new(false)),
|
||||
}))));
|
||||
let (mut client, tx) = fixture_client(stdin);
|
||||
let mut request =
|
||||
Box::pin(client.request::<Value>(McpPhase::Running, "tools/call", json!({})));
|
||||
assert_pending(request.as_mut()).await;
|
||||
timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(err.request_stage(), Some(McpRequestStage::AwaitingResponse));
|
||||
assert!(err.to_string().contains("execution unknown; do not retry"));
|
||||
}
|
||||
drop(request);
|
||||
// Reuse is safe only because the first frame completed and IDs are
|
||||
// matched. This is a different method, not a replay of the unknown call.
|
||||
tx.send(ReaderEvent::Response(ServerResponse {
|
||||
id: 1,
|
||||
result: Some(json!({"late": true})),
|
||||
error: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.send(ReaderEvent::Response(ServerResponse {
|
||||
id: 2,
|
||||
result: Some(json!({"next": true})),
|
||||
error: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = client
|
||||
.request::<Value>(McpPhase::Running, "next-method", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, json!({"next": true}));
|
||||
let captured = bytes.lock().unwrap().clone();
|
||||
let frames: Vec<Value> = captured
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| serde_json::from_slice(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0]["method"], "tools/call");
|
||||
assert_eq!(frames[1]["method"], "next-method");
|
||||
}
|
||||
}
|
||||
|
||||
// Observes actual OS-pipe backpressure, not a guessed delay or just a mock
|
||||
// Pending. It forwards every operation to ChildStdin without changing framing.
|
||||
struct PendingObserver<W> {
|
||||
writer: W,
|
||||
accepted: usize,
|
||||
reached: Option<mpsc::UnboundedSender<()>>,
|
||||
}
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for PendingObserver<W> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let result = Pin::new(&mut self.writer).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(count)) = &result {
|
||||
self.accepted += count;
|
||||
}
|
||||
if result.is_pending() && self.accepted > 1024 {
|
||||
if let Some(tx) = self.reached.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.writer).poll_flush(cx)
|
||||
}
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.writer).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn blocked_reader_reply_does_not_prevent_shutdown_process_reaping() {
|
||||
// A local stdio fixture: initialize, consume initialized, emit an unsupported
|
||||
// server request whose echoed ID exceeds pipe capacity, then never read.
|
||||
// The shell has no descendants and ignores TERM to also prove kill fallback.
|
||||
let init = json!({"jsonrpc":"2.0", "id":1, "result": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities":{}, "serverInfo":{"name":"fixture", "version":"1"}}});
|
||||
let server_request =
|
||||
json!({"jsonrpc":"2.0", "id":"x".repeat(256 * 1024), "method":"sampling/createMessage"});
|
||||
// Avoid argv limits: generate the large protocol ID in the shell itself.
|
||||
let script = format!(
|
||||
"trap '' TERM; read -r init; printf '%s\\n' '{init}'; read -r initialized; printf '%s' '{{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":\"'; i=0; while [ $i -lt 4096 ]; do printf '%s' '{}'; i=$((i+1)); done; printf '%s\\n' '\"}}'; while :; do :; done",
|
||||
"x".repeat(64)
|
||||
);
|
||||
assert!(server_request.to_string().len() < McpStdioLimits::default().max_stdout_line_bytes);
|
||||
let limits = McpStdioLimits {
|
||||
shutdown_timeout: Duration::from_millis(50),
|
||||
kill_timeout: Duration::from_millis(100),
|
||||
..Default::default()
|
||||
};
|
||||
let mut client = McpStdioClient::spawn(
|
||||
McpStdioServerSpec::new("reader-blocked", "/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script),
|
||||
limits,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pid = client.child.as_ref().unwrap().id().unwrap();
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
{
|
||||
let mut guard = client.stdin.lock().await;
|
||||
let writer = guard.take().unwrap();
|
||||
*guard = Some(Box::new(PendingObserver {
|
||||
writer,
|
||||
accepted: 0,
|
||||
reached: Some(reached),
|
||||
}));
|
||||
}
|
||||
client.initialize().await.unwrap();
|
||||
timeout(Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.expect("reader reply reached OS pipe Pending")
|
||||
.unwrap();
|
||||
assert!(
|
||||
client.stdin.try_lock().is_err(),
|
||||
"reader owns stdin while its reply is blocked"
|
||||
);
|
||||
let report = timeout(Duration::from_secs(1), client.shutdown())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(report.terminated && report.killed);
|
||||
assert!(report.exit_status.is_some());
|
||||
assert!(client.child.is_none());
|
||||
// Tokio wait already reaped the child; an additional wait cannot find it.
|
||||
let mut status = 0;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ECHILD)
|
||||
);
|
||||
}
|
||||
+18
-1
@@ -6,8 +6,11 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn main() {
|
||||
let mode = env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string());
|
||||
let mode = env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string()));
|
||||
match mode.as_str() {
|
||||
"stdin-unread" => stdin_unread(),
|
||||
"success" => success(),
|
||||
"tools" => tools_list(),
|
||||
"tools-call-normal" => tools_call_normal(),
|
||||
@@ -22,6 +25,20 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_unread() {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
write_json(json!({"jsonrpc": "2.0", "id": init["id"], "result": initialize_result()}));
|
||||
assert_eq!(read_json()["method"], "notifications/initialized");
|
||||
// Protocol barrier: the host only writes its oversized call after this ack.
|
||||
let ready = read_json();
|
||||
assert_eq!(ready["method"], "fixture/ready");
|
||||
write_json(json!({"jsonrpc": "2.0", "id": ready["id"], "result": {"ready": true}}));
|
||||
loop {
|
||||
thread::park();
|
||||
}
|
||||
}
|
||||
|
||||
fn success() {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
|
||||
@@ -278,3 +278,48 @@ async fn sampling_requests_fail_closed_and_are_not_advertised() {
|
||||
let shutdown = client.shutdown().await.expect("shutdown succeeds");
|
||||
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unread_stdin_request_times_out_during_write_and_closes_before_reuse() {
|
||||
let limits = McpStdioLimits {
|
||||
request_timeout: Duration::from_millis(100),
|
||||
..tight_limits()
|
||||
};
|
||||
let spec = McpStdioServerSpec::new("unread-stdin", env!("CARGO_BIN_EXE_mcp-stdio-mock-server"))
|
||||
.arg("stdin-unread");
|
||||
let mut client = McpStdioClient::connect(spec, limits).await.unwrap();
|
||||
let ready: serde_json::Value = client
|
||||
.request(McpPhase::Running, "fixture/ready", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ready["ready"], true);
|
||||
// Far beyond OS pipe capacity, but inside the existing protocol bound.
|
||||
let call = CallToolRequest::new(
|
||||
"write-blocked",
|
||||
serde_json::json!({"payload": "x".repeat(768 * 1024)}),
|
||||
);
|
||||
let err = tokio::time::timeout(Duration::from_secs(1), client.call_tool(call))
|
||||
.await
|
||||
.expect("the whole request, not only response wait, is bounded")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err.kind, McpErrorKind::Timeout { ref operation, .. } if operation == "tools/call")
|
||||
);
|
||||
assert_eq!(
|
||||
err.request_stage(),
|
||||
Some(mcp::stdio::McpRequestStage::WritingFrame)
|
||||
);
|
||||
// The fixture barrier and reported stage establish actual write backpressure;
|
||||
// the separate controlled fixture also verifies the exact partial bytes.
|
||||
let next = client
|
||||
.request::<serde_json::Value>(McpPhase::Running, "fixture/next", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(next.to_string().contains("stdin is closed"));
|
||||
let report = tokio::time::timeout(Duration::from_secs(1), client.shutdown())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(report.terminated || report.killed);
|
||||
assert!(report.exit_status.is_some());
|
||||
}
|
||||
|
||||
@@ -610,7 +610,9 @@ pub struct CompleteMergeRequest {
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
/// Historical MR-owned Ticket completion payload, retained for reading existing events.
|
||||
/// Canonical Ticket completion is owned by the Ticket domain.
|
||||
/// Current close/reopen/correction judgments belong to the Ticket domain and require no
|
||||
/// universal MR evidence gate. This archived payload is neither a current case operation nor
|
||||
/// authority to infer closure, self-reported handling, work release, or Worker termination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
@@ -1171,18 +1173,9 @@ impl MergeRequestStore {
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![mr.workspace_id, i.ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if state.as_deref() != Some("inprogress") {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
// Case openness is not an integration phase. Exact source approval,
|
||||
// current Ticket content and active assignment fencing above remain
|
||||
// the independent MR integration guards.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1254,18 +1247,9 @@ impl MergeRequestStore {
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = transaction
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![mr.workspace_id, i.ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if state.as_deref() != Some("inprogress") {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
// Case openness is not an integration phase. Exact source approval,
|
||||
// current Ticket content and active assignment fencing above remain
|
||||
// the independent MR integration guards.
|
||||
let issued_grants = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT request_event_id,subject_ref,capability_token
|
||||
|
||||
+773
-130
File diff suppressed because it is too large
Load Diff
@@ -438,6 +438,34 @@ fn validate_ticket_browser_operations(
|
||||
paths: &Map<String, Value>,
|
||||
) -> Result<BTreeSet<String>, GenerationError> {
|
||||
let operations = [
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/ticket-tags",
|
||||
method: "get",
|
||||
operation_id: "workspace_ticket_tag_list",
|
||||
request: None,
|
||||
successes: &[("200", "Array_of_WorkspaceTag")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/ticket-tags",
|
||||
method: "post",
|
||||
operation_id: "workspace_ticket_tag_mutate",
|
||||
request: Some("WorkspaceTagMutation"),
|
||||
successes: &[("200", "WorkspaceTag")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{id}/tags",
|
||||
method: "put",
|
||||
operation_id: "ticket_tags_set",
|
||||
request: Some("TicketTagSetRequest"),
|
||||
successes: &[("200", "Array_of_WorkspaceTag")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/query",
|
||||
method: "post",
|
||||
operation_id: "ticket_query",
|
||||
request: Some("TicketQueryRequest"),
|
||||
successes: &[("200", "TicketQueryResponse")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets",
|
||||
method: "get",
|
||||
@@ -481,11 +509,74 @@ fn validate_ticket_browser_operations(
|
||||
successes: &[("200", "TicketDetail")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{id}/state",
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
|
||||
method: "post",
|
||||
operation_id: "ticket_state_transition",
|
||||
request: Some("BrowserTransitionTicketStateRequest"),
|
||||
successes: &[("200", "TicketDetail")],
|
||||
operation_id: "ticket_case_update",
|
||||
request: Some("TicketCaseUpdateRequest"),
|
||||
successes: &[("200", "TicketSummary")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignments_page",
|
||||
request: None,
|
||||
successes: &[("200", "TicketAssignmentsPage")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignment_diagnostics",
|
||||
request: None,
|
||||
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
||||
method: "post",
|
||||
operation_id: "ticket_assignment_answer",
|
||||
request: Some("TicketAssignmentAnswer"),
|
||||
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignments_page_browser",
|
||||
request: None,
|
||||
successes: &[("200", "TicketAssignmentsPage")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignment_diagnostics_browser",
|
||||
request: None,
|
||||
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer",
|
||||
method: "post",
|
||||
operation_id: "ticket_assignment_answer_browser",
|
||||
request: Some("TicketAssignmentAnswer"),
|
||||
successes: &[("200", "TicketAssignmentAnswerReceipt")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignment_tracking_get",
|
||||
request: None,
|
||||
successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
|
||||
method: "post",
|
||||
operation_id: "ticket_assignment_tracking_update",
|
||||
request: Some("TicketAssignmentTrackingUpdate"),
|
||||
successes: &[("200", "TicketAssignmentTrackingProjection")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history",
|
||||
method: "get",
|
||||
operation_id: "ticket_assignment_tracking_history",
|
||||
request: None,
|
||||
successes: &[("200", "TicketAssignmentTrackingHistoryPage")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{id}/ready",
|
||||
@@ -508,13 +599,6 @@ fn validate_ticket_browser_operations(
|
||||
request: Some("BrowserQueueTicketRequest"),
|
||||
successes: &[("200", "TicketQueueOutcome")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/tickets/{id}/close",
|
||||
method: "post",
|
||||
operation_id: "ticket_close",
|
||||
request: Some("BrowserCloseTicketRequest"),
|
||||
successes: &[("200", "TicketDetail")],
|
||||
},
|
||||
OperationContract {
|
||||
path: "/api/w/{workspace_id}/objectives",
|
||||
method: "get",
|
||||
@@ -1906,6 +1990,54 @@ mod tests {
|
||||
assert!(!detail.contains(" target:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_browser_generator_exposes_case_judgment_and_closure_projections() {
|
||||
let openapi = crate::canonical_openapi_document()
|
||||
.unwrap()
|
||||
.to_json()
|
||||
.unwrap();
|
||||
let output = generate_ticket_browser_typescript(&openapi).unwrap();
|
||||
assert!(output.contains("export type BrowserTicketState = \"open\" | \"closed\";"));
|
||||
let request = output
|
||||
.lines()
|
||||
.find(|line| line.starts_with("export type TicketCaseUpdateRequest ="))
|
||||
.unwrap();
|
||||
for field in [
|
||||
"expected_content_digest: string",
|
||||
"expected_state: TicketState",
|
||||
"expected_closure_operation_key: string | null",
|
||||
"operation_key: string",
|
||||
"action: TicketCaseAction",
|
||||
"explanation: string",
|
||||
] {
|
||||
assert!(request.contains(field), "missing {field}: {request}");
|
||||
}
|
||||
assert!(!request.contains("author"));
|
||||
assert!(output.contains("export type TicketClosureReason = \"completed\" | \"not_planned\" | \"duplicate\" | \"unknown\" | { other: string; };"));
|
||||
let detail = output
|
||||
.lines()
|
||||
.find(|line| line.starts_with("export type TicketDetail ="))
|
||||
.unwrap();
|
||||
for field in [
|
||||
"state: BrowserTicketState",
|
||||
"closure_operation_key?: string | null",
|
||||
"current_closure?: TicketClosureJudgment | null",
|
||||
"closure_history: Array<TicketCaseHistoryEntry>",
|
||||
"legacy_handoff?: TicketLegacyWorkflowSnapshot | null",
|
||||
] {
|
||||
assert!(detail.contains(field), "missing {field}: {detail}");
|
||||
}
|
||||
for removed in [
|
||||
"BrowserTransitionTicketStateRequest",
|
||||
"BrowserCloseTicketRequest",
|
||||
"CompleteTicketRequest",
|
||||
"BrowserTicketWorkflowState",
|
||||
"done_not_closed",
|
||||
] {
|
||||
assert!(!output.contains(removed), "{removed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_in_ticket_browser_typescript_is_current() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -67,6 +67,8 @@ pub enum LoggedSessionHistoryOrigin {
|
||||
ToolOutput {
|
||||
worker: LoggedWorkerSubject,
|
||||
},
|
||||
/// Host-produced opening context summary; not auto-read or TaskStore material.
|
||||
CompactionSummary,
|
||||
DerivedSummary,
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
@@ -1888,7 +1888,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
||||
}
|
||||
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||
LoggedSessionHistoryOrigin::CompactionSummary
|
||||
| LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||
}
|
||||
}
|
||||
@@ -2559,6 +2560,12 @@ mod tests {
|
||||
user_message(10),
|
||||
assistant_message(10, "final"),
|
||||
synthetic_compaction_input,
|
||||
history_message(
|
||||
"host-summary",
|
||||
LoggedRole::System,
|
||||
"[Compacted context summary]\n\nprivate inherited context",
|
||||
LoggedSessionHistoryOrigin::CompactionSummary,
|
||||
),
|
||||
],
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
|
||||
@@ -6,6 +6,6 @@ Does not search Repository documents or handle Git integration.
|
||||
## Main entry points
|
||||
|
||||
- [`TicketBackend`], [`SqliteTicketBackend`]: operation boundary and persistence
|
||||
- [`TicketBackend::complete`], [`TicketBackend::update_state`]: completion decisions and state updates
|
||||
- [`TicketBackend::update_case`]: reasoned close, reopen, and closure correction
|
||||
- [`ticket_content_digest`]: requirement-content comparison condition
|
||||
- [`tool`]: Worker-facing operation definitions
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Continuing input is authored against an observed current delegation and handling
|
||||
//! event, never a new Assign. Only open cases with waiting/inprogress handling admit new
|
||||
//! answers; done requires explicit reissue. Answers append input without changing handling.
|
||||
//! The host fences authenticated source/current identity and saves input/receipt atomically,
|
||||
//! then notifies delivery after commit. Exact replay keeps its original key and full input;
|
||||
//! its saved receipt does not attest delivery, model consideration, or execution start.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentAnswer {
|
||||
pub assignment_id: String,
|
||||
pub expected_last_event_id: String,
|
||||
pub operation_key: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentAnswerReceipt {
|
||||
pub assignment_id: String,
|
||||
pub input_event_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub event_index: u64,
|
||||
}
|
||||
|
||||
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
|
||||
/// calling, including on replay. Reuse Ticket's existing immutable operation
|
||||
/// receipts with a tagged input; these are not handling events or another ledger.
|
||||
pub fn sqlite_answer_ticket_assignment_in_transaction(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
answer: &TicketAssignmentAnswer,
|
||||
source_context: &serde_json::Value,
|
||||
actor: &str,
|
||||
) -> Result<TicketAssignmentAnswerReceipt> {
|
||||
for value in [
|
||||
&answer.assignment_id,
|
||||
&answer.expected_last_event_id,
|
||||
&answer.operation_key,
|
||||
actor,
|
||||
] {
|
||||
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer requires bounded nonempty identities".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
|
||||
));
|
||||
}
|
||||
let input =
|
||||
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
|
||||
.map_err(sqlite_err)?;
|
||||
let source = serde_json::to_string(
|
||||
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let receipt: Option<(String,String,String,String)> = conn.query_row(
|
||||
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
|
||||
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
|
||||
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
|
||||
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: answer.operation_key.clone(),
|
||||
});
|
||||
}
|
||||
return serde_json::from_str(&saved_result).map_err(sqlite_err);
|
||||
}
|
||||
let current =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
|
||||
})?;
|
||||
if !current.current
|
||||
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|
||||
|| !current.tracking.as_ref().is_some_and(|tracking| {
|
||||
matches!(
|
||||
tracking.status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
| TicketAssignmentTrackingStatus::Inprogress
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
|
||||
}
|
||||
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
|
||||
if !open {
|
||||
return Err(TicketError::Conflict(
|
||||
"A closed Ticket does not admit continuing work input".into(),
|
||||
));
|
||||
}
|
||||
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
|
||||
let at = now_utc();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
|
||||
for (key, value) in [
|
||||
("orchestrator_assignment_id", answer.assignment_id.as_str()),
|
||||
(
|
||||
"expected_last_event_id",
|
||||
answer.expected_last_event_id.as_str(),
|
||||
),
|
||||
("operation_key", answer.operation_key.as_str()),
|
||||
("authenticated_source", source.as_str()),
|
||||
] {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id, at],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let result = TicketAssignmentAnswerReceipt {
|
||||
assignment_id: answer.assignment_id.clone(),
|
||||
input_event_id: format!("ticket-event:{index}"),
|
||||
event_index: index as u64,
|
||||
};
|
||||
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Ticket owns assignment identities, current pointers, audit and operation receipts.
|
||||
CREATE TABLE IF NOT EXISTS ticket_assignment_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assignment_id TEXT,
|
||||
expected_assignment_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
request_fingerprint TEXT,
|
||||
claim_state TEXT NOT NULL DEFAULT 'committed'
|
||||
CHECK(claim_state IN ('pending', 'failed', 'committed')),
|
||||
failure_reason TEXT,
|
||||
binding_recovery_json TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(claim_state = 'committed' OR assignment_id IS NULL),
|
||||
CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ticket_current_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
assignment_id TEXT NOT NULL,
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, assignment_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id, role, assignment_id)
|
||||
REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, ticket_id)
|
||||
REFERENCES typed_tickets(workspace_id, ticket_id)
|
||||
ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ticket_worker_assignment_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
event_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
|
||||
assignment_id TEXT,
|
||||
previous_assignment_id TEXT,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
operation_id TEXT,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ticket_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
assignment_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assigned_by TEXT NOT NULL,
|
||||
assigned_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket
|
||||
ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket
|
||||
ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_principal
|
||||
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket
|
||||
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ticket_current_principal_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ticket_current_singleton_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||
WHERE role IN ('orchestrator', 'worker');
|
||||
@@ -0,0 +1,964 @@
|
||||
//! Ticket-owned delegation identity and self-reported handling, not Runtime execution,
|
||||
//! grants, delivery acknowledgement, or MR evidence. Each explicit Assign/reissue has a
|
||||
//! new immutable identity, even for the same principal; Worker replacement is not Assign.
|
||||
//! Assign commits the current pointer, initial waiting, audit, and receipt together.
|
||||
//!
|
||||
//! Handling has no mandatory progression: waiting-to-done is valid without MR approval.
|
||||
//! Done does not close the case, release work, terminate a Worker, or clean up resources.
|
||||
//! Close/reopen does not change handling; current responsibility can report after close
|
||||
//! without restoring execution grants. Retained unknown handling is not inferred waiting/done.
|
||||
//!
|
||||
//! The host authenticates the current workspace-agent Worker and its actual Runtime
|
||||
//! binding, including retries, inside the same write transaction as receipt comparison,
|
||||
//! current-assignment/event CAS, audit, and result. Never split that transaction around
|
||||
//! awaits. A replayed receipt is not a fresh event observation: reread before a new update,
|
||||
//! and reconcile stale or unknown outcomes instead of silently issuing a new operation key.
|
||||
use super::*;
|
||||
|
||||
/// Used by the Workspace host to compose its stricter principal constraints.
|
||||
/// Historical physical names remain to preserve immutable IDs and receipts.
|
||||
pub const TICKET_ASSIGNMENT_SCHEMA_SQL: &str = include_str!("assignment_schema.sql");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TicketAssignmentTrackingStatus {
|
||||
Waiting,
|
||||
Inprogress,
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentTracking {
|
||||
pub status: TicketAssignmentTrackingStatus,
|
||||
pub waiting_reason: Option<String>,
|
||||
pub progress: Option<String>,
|
||||
pub next_action: Option<String>,
|
||||
pub result: Option<String>,
|
||||
}
|
||||
|
||||
/// Replaces the complete handling payload against this delegation's observed event,
|
||||
/// not status alone. Exact retry compares full input and authenticated sender/binding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentTrackingUpdate {
|
||||
pub assignment_id: String,
|
||||
pub expected_last_event_id: String,
|
||||
pub operation_key: String,
|
||||
pub tracking: TicketAssignmentTracking,
|
||||
}
|
||||
|
||||
/// Backend-owned authenticated context, never decoded from model input. Agent
|
||||
/// principal remains stable across Worker exchange; exact replay binds to the
|
||||
/// authenticated concrete sender and its binding, not merely a claimed role.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct TicketAssignmentTrackingSourceContext {
|
||||
pub actor: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub agent_key: String,
|
||||
pub authentication_binding_id: String,
|
||||
}
|
||||
|
||||
/// Enrichment is separate from the established assignment record. `None`
|
||||
/// tracking means a retained historical relationship with insufficient delegation
|
||||
/// evidence, not waiting/done. Removal/replacement does not alter last tracking.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentTrackingProjection {
|
||||
pub workspace_id: String,
|
||||
pub ticket_id: String,
|
||||
pub assignment_id: String,
|
||||
pub role: String,
|
||||
pub principal: TicketAssignmentPrincipalSnapshot,
|
||||
pub assigned_by: String,
|
||||
pub assigned_at: String,
|
||||
pub release: Option<TicketAssignmentRelease>,
|
||||
pub current: bool,
|
||||
pub tracking: Option<TicketAssignmentTracking>,
|
||||
/// Tracking event, or the existing immutable Assign audit observation for a
|
||||
/// retained current relationship with no tracking yet. This baseline does
|
||||
/// not imply a handling status, delegation intent, or tracking timestamp.
|
||||
pub last_event_id: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub migration_diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable principal as stored at Assign, not live Worker availability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentPrincipalSnapshot {
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
Worker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
WorkspaceAgent {
|
||||
agent_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentReleaseAction {
|
||||
Unassigned,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
/// Release is an independent immutable assignment audit event. Last tracking
|
||||
/// remains unchanged, including unknown legacy tracking; it is never made done.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentRelease {
|
||||
pub action: TicketAssignmentReleaseAction,
|
||||
pub event_id: String,
|
||||
pub operation_key: Option<String>,
|
||||
pub actor: String,
|
||||
pub at: String,
|
||||
pub reason: Option<String>,
|
||||
pub replacement_assignment_id: Option<String>,
|
||||
pub last_tracking: Option<TicketAssignmentTracking>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentTrackingHistoryEntry {
|
||||
pub event_id: String,
|
||||
pub operation_key: String,
|
||||
pub tracking: TicketAssignmentTracking,
|
||||
pub source_context: serde_json::Value,
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Bounded append-order page. Cursor is an immutable event identity, not a
|
||||
/// lexical event-ID comparison or a transferable offset into another assignment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentTrackingHistoryPage {
|
||||
pub items: Vec<TicketAssignmentTrackingHistoryEntry>,
|
||||
pub next_event_id: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_identity(value: &str) -> bool {
|
||||
!value.trim().is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn validate_update(
|
||||
update: &TicketAssignmentTrackingUpdate,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<()> {
|
||||
if [
|
||||
&update.assignment_id,
|
||||
&update.expected_last_event_id,
|
||||
&update.operation_key,
|
||||
&source.actor,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.authentication_binding_id,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|value| !valid_identity(value))
|
||||
|| source.agent_key != "workspace-orchestrator"
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking requires bounded identities and authenticated workspace-orchestrator source"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
for text in [
|
||||
&update.tracking.waiting_reason,
|
||||
&update.tracking.progress,
|
||||
&update.tracking.next_action,
|
||||
&update.tracking.result,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if text.len() > 16 * 1024 || text.contains('\0') {
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking fields must be at most 16384 bytes without NUL".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads responsibility independently of case state and operational work release.
|
||||
pub fn sqlite_ticket_assignment_tracking(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Option<TicketAssignmentTrackingProjection>> {
|
||||
let row = conn.query_row("SELECT a.tracking_json, a.last_tracking_event_id, a.tracking_updated_at, a.tracking_migration_diagnostic,
|
||||
EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id),
|
||||
a.role, a.principal_kind, a.principal_id, a.runtime_id, a.worker_id, a.assigned_by, a.assigned_at
|
||||
FROM ticket_worker_assignments a WHERE a.workspace_id=?1 AND a.ticket_id=?2 AND a.assignment_id=?3 AND a.role='orchestrator'",
|
||||
params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,Option<String>>(0)?,r.get::<_,Option<String>>(1)?,r.get::<_,Option<String>>(2)?,r.get::<_,Option<String>>(3)?,r.get::<_,bool>(4)?, r.get::<_,String>(5)?, r.get::<_,String>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,Option<String>>(8)?,r.get::<_,Option<String>>(9)?,r.get::<_,String>(10)?,r.get::<_,String>(11)?)))
|
||||
.optional().map_err(sqlite_err)?;
|
||||
let Some((
|
||||
json,
|
||||
last_event_id,
|
||||
updated_at,
|
||||
migration_diagnostic,
|
||||
current,
|
||||
role,
|
||||
kind,
|
||||
principal_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
assigned_by,
|
||||
assigned_at,
|
||||
)) = row
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let tracking: Option<TicketAssignmentTracking> = json
|
||||
.map(|value| serde_json::from_str(&value).map_err(sqlite_err))
|
||||
.transpose()?;
|
||||
// Legacy migration may retain current responsibility without evidence of
|
||||
// handling status. Its real Assign audit is still an immutable observation
|
||||
// against which the first explicit self-report can CAS, even after close.
|
||||
// Never manufacture a tracking event/status or repair a missing audit. Once
|
||||
// tracking exists, only its event identity is a valid observation.
|
||||
let last_event_id = if current && tracking.is_none() && last_event_id.is_none() {
|
||||
conn.query_row(
|
||||
"SELECT e.event_id FROM ticket_worker_assignment_events e
|
||||
WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND e.assignment_id=?3
|
||||
AND e.role='orchestrator' AND e.action IN ('assigned','reassigned')
|
||||
AND NOT EXISTS (SELECT 1 FROM typed_ticket_assignment_tracking_history h
|
||||
WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3)
|
||||
ORDER BY e.rowid LIMIT 1",
|
||||
params![workspace_id, ticket_id, assignment_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?
|
||||
} else {
|
||||
last_event_id
|
||||
};
|
||||
let missing =
|
||||
|| TicketError::Conflict("stored assignment principal identity is incomplete".into());
|
||||
let principal = match kind.as_str() {
|
||||
"workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
"user" => TicketAssignmentPrincipalSnapshot::User {
|
||||
account_id: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
"worker" => TicketAssignmentPrincipalSnapshot::Worker {
|
||||
runtime_id: runtime_id.ok_or_else(missing)?,
|
||||
worker_id: worker_id.ok_or_else(missing)?,
|
||||
},
|
||||
_ => return Err(missing()),
|
||||
};
|
||||
let release = conn.query_row("SELECT action,event_id,operation_id,actor,created_at,reason,assignment_id FROM ticket_worker_assignment_events
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND ((action='unassigned' AND (assignment_id=?3 OR previous_assignment_id=?3)) OR (action='reassigned' AND previous_assignment_id=?3)) ORDER BY rowid DESC LIMIT 1",
|
||||
params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,Option<String>>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Option<String>>(5)?,r.get::<_,Option<String>>(6)?))).optional().map_err(sqlite_err)?
|
||||
.map(|(action,event_id,operation_key,actor,at,reason,replacement)| TicketAssignmentRelease {
|
||||
action: if action=="unassigned" {TicketAssignmentReleaseAction::Unassigned} else {TicketAssignmentReleaseAction::Replaced},
|
||||
event_id,operation_key,actor,at,reason,replacement_assignment_id: if action=="reassigned" {replacement} else {None}, last_tracking: tracking.clone(),
|
||||
});
|
||||
Ok(Some(TicketAssignmentTrackingProjection {
|
||||
workspace_id: workspace_id.into(),
|
||||
ticket_id: ticket_id.into(),
|
||||
assignment_id: assignment_id.into(),
|
||||
role,
|
||||
principal,
|
||||
assigned_by,
|
||||
assigned_at,
|
||||
release,
|
||||
current,
|
||||
tracking,
|
||||
last_event_id,
|
||||
updated_at,
|
||||
migration_diagnostic,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn sqlite_current_ticket_assignment_tracking(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingProjection>> {
|
||||
let ids = conn.prepare("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' ORDER BY assignment_id")
|
||||
.map_err(sqlite_err)?.query_map(params![workspace_id,ticket_id],|r|r.get::<_,String>(0)).map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?;
|
||||
ids.into_iter()
|
||||
.map(|id| {
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &id)?
|
||||
.ok_or_else(|| TicketError::Conflict("current assignment row is missing".into()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sqlite_ticket_assignment_tracking_history(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingHistoryEntry>> {
|
||||
let mut stmt=conn.prepare("SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h
|
||||
WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 ORDER BY h.rowid").map_err(sqlite_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![workspace_id, ticket_id, assignment_id], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(sqlite_err)?;
|
||||
rows.map(|r| {
|
||||
let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?;
|
||||
Ok(TicketAssignmentTrackingHistoryEntry {
|
||||
event_id,
|
||||
operation_key,
|
||||
tracking: serde_json::from_str(&json).map_err(sqlite_err)?,
|
||||
source_context: serde_json::from_str(&source).map_err(sqlite_err)?,
|
||||
at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// DB-bounded history pagination. Immutable append-only rows provide a stable
|
||||
/// cursor position; the scope index also orders its implicit rowids. No caller
|
||||
/// needs to load, deserialize or truncate the lifetime assignment history.
|
||||
pub fn sqlite_ticket_assignment_tracking_history_page(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
after_event_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<TicketAssignmentTrackingHistoryPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking history limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
let exists:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator')",params![workspace_id,ticket_id,assignment_id],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
if !exists {
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking assignment is not in this Workspace/Ticket".into(),
|
||||
));
|
||||
}
|
||||
let after_rowid:Option<i64>=after_event_id.map(|event| conn.query_row("SELECT rowid FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND event_id=?4",params![workspace_id,ticket_id,assignment_id,event],|r|r.get::<_,i64>(0)).optional().map_err(sqlite_err)?.ok_or_else(||TicketError::Conflict("tracking history cursor is not in this exact Workspace/Ticket/assignment".into()))).transpose()?;
|
||||
let after_clause = if after_rowid.is_some() {
|
||||
"AND h.rowid>?4"
|
||||
} else {
|
||||
"AND ?4 IS NULL"
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 {after_clause} ORDER BY h.rowid LIMIT ?5"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(sqlite_err)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
after_rowid,
|
||||
(limit + 1) as i64
|
||||
],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let mut items = rows
|
||||
.map(|r| {
|
||||
let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?;
|
||||
Ok(TicketAssignmentTrackingHistoryEntry {
|
||||
event_id,
|
||||
operation_key,
|
||||
tracking: serde_json::from_str(&json).map_err(sqlite_err)?,
|
||||
source_context: serde_json::from_str(&source).map_err(sqlite_err)?,
|
||||
at,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let has_more = items.len() > limit;
|
||||
items.truncate(limit);
|
||||
let next_event_id = if has_more {
|
||||
items.last().map(|entry| entry.event_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(TicketAssignmentTrackingHistoryPage {
|
||||
items,
|
||||
next_event_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Host owns BEGIN IMMEDIATE/commit. Receipt lookup precedes current/CAS checks;
|
||||
/// exact replay returns the immutable operation result even after reassignment.
|
||||
/// Case closed and work-release records are deliberately absent from this guard.
|
||||
pub fn sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
update: &TicketAssignmentTrackingUpdate,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<TicketAssignmentTrackingProjection> {
|
||||
if conn.is_autocommit() {
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking update requires a host write transaction".into(),
|
||||
));
|
||||
}
|
||||
validate_update(update, source)?;
|
||||
let input_json = serde_json::to_string(&(ticket_id, update)).map_err(sqlite_err)?;
|
||||
let source_json = serde_json::to_string(source).map_err(sqlite_err)?;
|
||||
let receipt=conn.query_row("SELECT input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",params![workspace_id,update.operation_key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?))).optional().map_err(sqlite_err)?;
|
||||
if let Some((input, context, result)) = receipt {
|
||||
if input != input_json || context != source_json {
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking operation key already used for different input or authenticated source"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
return serde_json::from_str(&result).map_err(sqlite_err);
|
||||
}
|
||||
let current =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict(
|
||||
"tracking assignment not found in this Workspace/Ticket".into(),
|
||||
)
|
||||
})?;
|
||||
if !current.current
|
||||
|| current.last_event_id.as_deref() != Some(update.expected_last_event_id.as_str())
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking assignment is removed/replaced or last event changed".into(),
|
||||
));
|
||||
}
|
||||
let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
if !principal_matches {
|
||||
return Err(TicketError::Conflict(
|
||||
"authenticated agent does not own this assignment".into(),
|
||||
));
|
||||
}
|
||||
let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?);
|
||||
let at = Utc::now().to_rfc3339();
|
||||
let tracking_json = serde_json::to_string(&update.tracking).map_err(sqlite_err)?;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,tracking_json,event_id,at]).map_err(sqlite_err)?;
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
update.assignment_id,
|
||||
event_id,
|
||||
update.operation_key,
|
||||
tracking_json,
|
||||
source_json,
|
||||
at
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let result =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)?
|
||||
.ok_or_else(|| TicketError::Conflict("tracking assignment disappeared".into()))?;
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_assignment_tracking_operations VALUES (?1,?2,?3,?4,?5,?6)",
|
||||
params![
|
||||
workspace_id,
|
||||
update.operation_key,
|
||||
ticket_id,
|
||||
input_json,
|
||||
source_json,
|
||||
serde_json::to_string(&result).map_err(sqlite_err)?
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn install_assignment_core(conn: &Connection) -> Result<()> {
|
||||
// Standalone Ticket stores do not own Workspace records. The Workspace host
|
||||
// composes the unmodified SQL and adds its principal/resource constraints.
|
||||
let sql = if conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='workspaces')",
|
||||
[],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(sqlite_err)?
|
||||
{
|
||||
TICKET_ASSIGNMENT_SCHEMA_SQL.to_owned()
|
||||
} else {
|
||||
TICKET_ASSIGNMENT_SCHEMA_SQL.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "").replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n")
|
||||
};
|
||||
conn.execute_batch(&sql).map_err(sqlite_err)
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_assignment_tracking(conn: &Connection) -> Result<()> {
|
||||
install_assignment_core(conn)?;
|
||||
conn.execute_batch(include_str!("assignment_tracking_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
reconcile_legacy_tracking(conn)
|
||||
}
|
||||
|
||||
fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> {
|
||||
let rows=conn.prepare("SELECT a.workspace_id,a.ticket_id,a.assignment_id,s.snapshot_json,
|
||||
EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id)
|
||||
FROM ticket_worker_assignments a LEFT JOIN typed_ticket_legacy_workflow_snapshot s ON s.workspace_id=a.workspace_id AND s.ticket_id=a.ticket_id WHERE a.role='orchestrator'").map_err(sqlite_err)?
|
||||
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,Option<String>>(3)?,r.get::<_,bool>(4)?))).map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?;
|
||||
for (workspace, ticket, assignment, snapshot, current) in rows {
|
||||
let original = snapshot
|
||||
.as_deref()
|
||||
.map(serde_json::from_str::<TicketLegacyWorkflowSnapshot>)
|
||||
.transpose()
|
||||
.map_err(sqlite_err)?;
|
||||
// Read the latest routing intention, not just the latest matching
|
||||
// fence: a newer mismatched Queue must not revive an older delegation.
|
||||
let queue=conn.query_row("SELECT e.event_index,e.at,(e.state_field='routing' AND e.reason='queued'),f.value FROM typed_ticket_events e LEFT JOIN typed_ticket_event_attributes f ON f.workspace_id=e.workspace_id AND f.ticket_id=e.ticket_id AND f.event_index=e.event_index AND f.key='orchestrator_assignment_id'
|
||||
WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued')) ORDER BY e.event_index DESC LIMIT 1",params![workspace,ticket],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,Option<String>>(1)?,r.get::<_,Option<bool>>(2)?.unwrap_or(false),r.get::<_,Option<String>>(3)?))).optional().map_err(sqlite_err)?;
|
||||
let original_active = original
|
||||
.as_ref()
|
||||
.is_some_and(|s| matches!(s.workflow_state.as_str(), "queued" | "inprogress"));
|
||||
if let Some((index, at, typed_queue, _)) = queue.filter(|(_, _, typed_queue, fence)| {
|
||||
current
|
||||
&& (original_active || *typed_queue)
|
||||
&& fence.as_deref() == Some(assignment.as_str())
|
||||
}) {
|
||||
// A case judgment is earlier only when its immutable operation key
|
||||
// identifies a case event before Queue. Missing correspondence is
|
||||
// ambiguous; neither timestamp order nor Worker state repairs it.
|
||||
let later_case:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.request_fingerprint!='migration-v10'
|
||||
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes operation JOIN typed_ticket_events e ON e.workspace_id=operation.workspace_id AND e.ticket_id=operation.ticket_id AND e.event_index=operation.event_index
|
||||
WHERE operation.workspace_id=h.workspace_id AND operation.ticket_id=h.ticket_id AND operation.key='operation_key' AND operation.value=h.operation_key AND e.state_field='case' AND e.event_index<?3))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
let superseded:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND event_index>?3 AND (state_field='case' OR to_state IN ('planning','ready','done','closed') OR (to_state='open' AND COALESCE(state_field,'')!='routing')))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
if !later_case && !superseded {
|
||||
let status = if !typed_queue
|
||||
&& original
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.workflow_state == "inprogress")
|
||||
{
|
||||
TicketAssignmentTrackingStatus::Inprogress
|
||||
} else {
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
};
|
||||
let tracking = TicketAssignmentTracking {
|
||||
status,
|
||||
waiting_reason: Some(
|
||||
"Migrated fenced legacy delegation; Orchestrator confirmation pending"
|
||||
.into(),
|
||||
),
|
||||
progress: None,
|
||||
next_action: None,
|
||||
result: None,
|
||||
};
|
||||
let event_id = format!("migration-tracking:{assignment}");
|
||||
let at = at.unwrap_or_else(|| Utc::now().to_rfc3339());
|
||||
let json = serde_json::to_string(&tracking).map_err(sqlite_err)?;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5,tracking_migration_diagnostic=?6 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment,json,event_id,at,format!("Retained original workflow snapshot and latest Queue event {index} (typed routing={typed_queue}) fenced to this current assignment")]).map_err(sqlite_err)?;
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?4,?5,'{\"kind\":\"migration\"}',?6)",params![workspace,ticket,assignment,event_id,json,at]).map_err(sqlite_err)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_migration_diagnostic='Historical assignment retained without tracking: absent/ambiguous or superseded original workflow and exact Queue fence; no delegation or done inferred' WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment]).map_err(sqlite_err)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> {
|
||||
// Object names alone would accept same-named no-op triggers after drift.
|
||||
let canonical = Connection::open_in_memory().map_err(sqlite_err)?;
|
||||
canonical
|
||||
.execute_batch(TICKET_ASSIGNMENT_SCHEMA_SQL)
|
||||
.map_err(sqlite_err)?;
|
||||
canonical
|
||||
.execute_batch(include_str!("assignment_tracking_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
let normalize = |sql: String| {
|
||||
// SQL keyword/format differences are harmless; literal case and spaces
|
||||
// are not (for example changing the principal or role in a guard).
|
||||
let mut result = String::new();
|
||||
let mut quoted = false;
|
||||
let mut chars = sql.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\'' {
|
||||
result.push(ch);
|
||||
if quoted && chars.peek() == Some(&'\'') {
|
||||
result.push(chars.next().unwrap());
|
||||
} else {
|
||||
quoted = !quoted;
|
||||
}
|
||||
} else if quoted {
|
||||
result.push(ch);
|
||||
} else if !ch.is_whitespace() {
|
||||
result.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
result.replace("ifnotexists", "")
|
||||
};
|
||||
for name in [
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
"typed_ticket_assignment_tracking_initialize",
|
||||
"typed_ticket_assignment_tracking_assigned",
|
||||
"typed_ticket_assignment_tracking_history_no_update",
|
||||
"typed_ticket_assignment_tracking_history_no_delete",
|
||||
"typed_ticket_assignment_tracking_operations_no_update",
|
||||
"typed_ticket_assignment_tracking_operations_no_delete",
|
||||
"ticket_current_singleton_role_idx",
|
||||
"typed_ticket_assignment_identity_no_update",
|
||||
"typed_ticket_assignment_identity_no_delete",
|
||||
"typed_ticket_assignment_events_no_update",
|
||||
"typed_ticket_assignment_events_no_delete",
|
||||
"typed_ticket_assignment_receipt_no_update",
|
||||
"typed_ticket_assignment_receipt_no_delete",
|
||||
"typed_ticket_assignment_current_principal_insert",
|
||||
"typed_ticket_assignment_current_principal_update",
|
||||
"typed_ticket_assignment_orchestrator_principal_insert",
|
||||
"typed_ticket_assignment_released_tracking_no_update",
|
||||
"typed_ticket_assignment_parent_insert",
|
||||
"typed_ticket_assignment_role_principal_insert",
|
||||
"typed_ticket_assignment_audit_parent_insert",
|
||||
"typed_ticket_assignment_operation_parent_insert",
|
||||
] {
|
||||
let expected: String = canonical
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let actual: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?;
|
||||
if actual.map(&normalize).as_deref() != Some(normalize(expected).as_str()) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket assignment tracking constraint {name} differs from canonical schema"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for (table, columns) in [
|
||||
(
|
||||
"ticket_worker_assignments",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"ticket_id",
|
||||
"assignment_id",
|
||||
"role",
|
||||
"principal_kind",
|
||||
"principal_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"assigned_by",
|
||||
"assigned_at",
|
||||
"tracking_json",
|
||||
"last_tracking_event_id",
|
||||
"tracking_updated_at",
|
||||
"tracking_migration_diagnostic",
|
||||
],
|
||||
),
|
||||
(
|
||||
"ticket_current_worker_assignments",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"ticket_id",
|
||||
"role",
|
||||
"assignment_id",
|
||||
"principal_kind",
|
||||
"principal_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"updated_at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"ticket_worker_assignment_events",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"ticket_id",
|
||||
"role",
|
||||
"event_id",
|
||||
"action",
|
||||
"assignment_id",
|
||||
"previous_assignment_id",
|
||||
"actor",
|
||||
"created_at",
|
||||
"operation_id",
|
||||
"reason",
|
||||
],
|
||||
),
|
||||
(
|
||||
"ticket_assignment_operations",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"operation_id",
|
||||
"action",
|
||||
"ticket_id",
|
||||
"role",
|
||||
"principal_kind",
|
||||
"principal_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"assignment_id",
|
||||
"expected_assignment_id",
|
||||
"created_at",
|
||||
"request_fingerprint",
|
||||
"claim_state",
|
||||
"failure_reason",
|
||||
"binding_recovery_json",
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"ticket_id",
|
||||
"assignment_id",
|
||||
"event_id",
|
||||
"operation_key",
|
||||
"tracking_json",
|
||||
"source_context_json",
|
||||
"at",
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
vec![
|
||||
"workspace_id",
|
||||
"operation_key",
|
||||
"ticket_id",
|
||||
"input_json",
|
||||
"source_context_json",
|
||||
"result_json",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let actual = conn
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.map_err(sqlite_err)?
|
||||
.query_map([], |r| r.get::<_, String>(1))
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
if actual != columns {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} has unexpected columns"
|
||||
)));
|
||||
}
|
||||
// Verify type, nullability, defaults and key positions, not just names.
|
||||
let shape = |db: &Connection| -> Result<Vec<(String, String, i64, Option<String>, i64)>> {
|
||||
db.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.map_err(sqlite_err)?
|
||||
.query_map([], |r| {
|
||||
Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)
|
||||
};
|
||||
if shape(conn)? != shape(&canonical)? {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} type/default/key constraints differ"
|
||||
)));
|
||||
}
|
||||
let foreign_keys = |db: &Connection| -> Result<BTreeSet<(String, String, String, String)>> {
|
||||
db.prepare(&format!("PRAGMA foreign_key_list({table})"))
|
||||
.map_err(sqlite_err)?
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.filter_map(|r| match r {
|
||||
Ok(key) if key.0 == "workspaces" => None,
|
||||
other => Some(other),
|
||||
})
|
||||
.collect::<std::result::Result<BTreeSet<_>, _>>()
|
||||
.map_err(sqlite_err)
|
||||
};
|
||||
if !foreign_keys(&canonical)?.is_subset(&foreign_keys(conn)?) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} lacks canonical foreign keys"
|
||||
)));
|
||||
}
|
||||
let expected_sql: String = canonical
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[table],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let actual_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[table],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let expected_sql = normalize(expected_sql);
|
||||
let actual_sql = normalize(actual_sql);
|
||||
let mut remainder = expected_sql.as_str();
|
||||
while let Some(start) = remainder.find("check(") {
|
||||
let check = &remainder[start..];
|
||||
let mut depth = 0i64;
|
||||
let mut end = None;
|
||||
for (index, ch) in check.char_indices().skip(5) {
|
||||
if ch == '(' {
|
||||
depth += 1;
|
||||
} else if ch == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(index + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let end = end.ok_or_else(|| {
|
||||
TicketError::Sqlite("canonical assignment CHECK is malformed".into())
|
||||
})?;
|
||||
if !actual_sql.contains(&check[..end]) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} CHECK constraint differs"
|
||||
)));
|
||||
}
|
||||
remainder = &check[end..];
|
||||
}
|
||||
let index_keys = |db: &Connection| -> Result<BTreeSet<(bool, bool, Vec<String>)>> {
|
||||
let indices = db
|
||||
.prepare(&format!("PRAGMA index_list({table})"))
|
||||
.map_err(sqlite_err)?
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, bool>(2)?,
|
||||
r.get::<_, bool>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
indices
|
||||
.into_iter()
|
||||
.map(|(name, unique, partial)| {
|
||||
let columns = db
|
||||
.prepare(&format!("PRAGMA index_info({name})"))
|
||||
.map_err(sqlite_err)?
|
||||
.query_map([], |r| r.get::<_, String>(2))
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
Ok((unique, partial, columns))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if !index_keys(&canonical)?.is_subset(&index_keys(conn)?) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} lacks canonical key/index constraints"
|
||||
)));
|
||||
}
|
||||
let violations: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_foreign_key_check('{table}')"),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
if violations != 0 {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket-owned assignment schema {table} foreign key violations"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for name in [
|
||||
"typed_ticket_assignment_tracking_initialize",
|
||||
"typed_ticket_assignment_tracking_assigned",
|
||||
"typed_ticket_assignment_tracking_history_no_update",
|
||||
"typed_ticket_assignment_tracking_history_no_delete",
|
||||
"typed_ticket_assignment_tracking_operations_no_update",
|
||||
"typed_ticket_assignment_tracking_operations_no_delete",
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='trigger' AND name=?1)",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
if !exists {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"Ticket assignment tracking constraint {name} missing"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "assignment_tracking_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentsPage {
|
||||
pub items: Vec<TicketAssignmentTrackingProjection>,
|
||||
pub next_assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable append-order identities. The cursor must be an identity in this
|
||||
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
|
||||
pub fn sqlite_ticket_assignments_page(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
after_assignment_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<TicketAssignmentsPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Assignment page limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
let after: i64 = match after_assignment_id {
|
||||
None => 0,
|
||||
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
|
||||
};
|
||||
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
|
||||
let mut ids = stmt
|
||||
.query_map(
|
||||
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
let has_more = ids.len() > limit;
|
||||
ids.truncate(limit);
|
||||
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
|
||||
let items = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(TicketAssignmentsPage {
|
||||
items,
|
||||
next_assignment_id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
-- Applied only by component 10 -> 11, never by request handlers.
|
||||
ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_json TEXT
|
||||
CHECK(tracking_json IS NULL OR (role = 'orchestrator' AND json_valid(tracking_json) AND json_type(tracking_json)='object'
|
||||
AND COALESCE(json_extract(tracking_json, '$.status') IN ('waiting', 'inprogress', 'done'), 0)));
|
||||
ALTER TABLE ticket_worker_assignments ADD COLUMN last_tracking_event_id TEXT;
|
||||
ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_updated_at TEXT;
|
||||
ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_migration_diagnostic TEXT;
|
||||
CREATE TABLE typed_ticket_assignment_tracking_history (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
assignment_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
tracking_json TEXT NOT NULL CHECK(json_valid(tracking_json)),
|
||||
source_context_json TEXT NOT NULL CHECK(json_valid(source_context_json)),
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, event_id),
|
||||
UNIQUE(workspace_id, assignment_id, operation_key),
|
||||
FOREIGN KEY(workspace_id, ticket_id, assignment_id)
|
||||
REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id)
|
||||
);
|
||||
CREATE TABLE typed_ticket_assignment_tracking_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
input_json TEXT NOT NULL,
|
||||
source_context_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, operation_key)
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_initialize
|
||||
AFTER INSERT ON ticket_worker_assignments WHEN NEW.role = 'orchestrator'
|
||||
BEGIN
|
||||
UPDATE ticket_worker_assignments SET tracking_json = '{"status":"waiting","waiting_reason":"Initial judgment pending","progress":null,"next_action":null,"result":null}', tracking_updated_at = NEW.assigned_at
|
||||
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id;
|
||||
END;
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_assigned
|
||||
AFTER INSERT ON ticket_worker_assignment_events
|
||||
WHEN NEW.role = 'orchestrator' AND NEW.action IN ('assigned', 'reassigned')
|
||||
BEGIN
|
||||
UPDATE ticket_worker_assignments SET last_tracking_event_id = NEW.event_id, tracking_updated_at = NEW.created_at
|
||||
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL;
|
||||
INSERT INTO typed_ticket_assignment_tracking_history
|
||||
SELECT workspace_id, ticket_id, assignment_id, NEW.event_id, COALESCE(NEW.operation_id, NEW.event_id), tracking_json,
|
||||
json_object('actor', NEW.actor, 'kind', 'assignment'), NEW.created_at
|
||||
FROM ticket_worker_assignments
|
||||
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL;
|
||||
END;
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update
|
||||
BEFORE UPDATE ON typed_ticket_assignment_tracking_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_history_no_delete
|
||||
BEFORE DELETE ON typed_ticket_assignment_tracking_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_update
|
||||
BEFORE UPDATE ON typed_ticket_assignment_tracking_operations
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_delete
|
||||
BEFORE DELETE ON typed_ticket_assignment_tracking_operations
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END;
|
||||
-- Original assignment identity/principal/audit is append-only. Tracking is the
|
||||
-- only mutable assignment value; startup claims are mutable only while pending.
|
||||
CREATE TRIGGER typed_ticket_assignment_identity_no_update
|
||||
BEFORE UPDATE OF workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at ON ticket_worker_assignments
|
||||
WHEN OLD.workspace_id IS NOT NEW.workspace_id OR OLD.ticket_id IS NOT NEW.ticket_id
|
||||
OR OLD.assignment_id IS NOT NEW.assignment_id OR OLD.role IS NOT NEW.role
|
||||
OR OLD.principal_kind IS NOT NEW.principal_kind OR OLD.principal_id IS NOT NEW.principal_id
|
||||
OR OLD.runtime_id IS NOT NEW.runtime_id OR OLD.worker_id IS NOT NEW.worker_id
|
||||
OR OLD.assigned_by IS NOT NEW.assigned_by OR OLD.assigned_at IS NOT NEW.assigned_at
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_identity_no_delete BEFORE DELETE ON ticket_worker_assignments
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_events_no_update BEFORE UPDATE ON ticket_worker_assignment_events
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_events_no_delete BEFORE DELETE ON ticket_worker_assignment_events
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_receipt_no_update BEFORE UPDATE ON ticket_assignment_operations
|
||||
WHEN OLD.claim_state != 'pending'
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_receipt_no_delete BEFORE DELETE ON ticket_assignment_operations
|
||||
WHEN OLD.claim_state != 'pending'
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_current_principal_insert BEFORE INSERT ON ticket_current_worker_assignments
|
||||
WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id
|
||||
AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role
|
||||
AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id
|
||||
AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id)
|
||||
OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id
|
||||
AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id))
|
||||
OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id)))
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_current_principal_update BEFORE UPDATE ON ticket_current_worker_assignments
|
||||
WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id
|
||||
AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role
|
||||
AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id
|
||||
AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id)
|
||||
OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id
|
||||
AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id))
|
||||
OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id)))
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN NEW.role='orchestrator' AND (NEW.principal_kind!='workspace_agent' OR NEW.principal_id!='workspace-orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT, 'Orchestrator assignment requires the stable Workspace agent principal'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_released_tracking_no_update
|
||||
BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments
|
||||
WHEN EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=OLD.workspace_id
|
||||
AND ((e.action='unassigned' AND (e.assignment_id=OLD.assignment_id OR e.previous_assignment_id=OLD.assignment_id))
|
||||
OR (e.action='reassigned' AND e.previous_assignment_id=OLD.assignment_id)))
|
||||
BEGIN SELECT RAISE(ABORT, 'Released assignment tracking is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_parent_insert BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'New assignment requires a Ticket in the same Workspace'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_role_principal_insert BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN (NEW.role='owner' AND NEW.principal_kind!='user')
|
||||
OR (NEW.role='worker' AND NEW.principal_kind='workspace_agent')
|
||||
OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT, 'Assignment role and principal are incompatible'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_audit_parent_insert BEFORE INSERT ON ticket_worker_assignment_events
|
||||
WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.assignment_id))
|
||||
OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.previous_assignment_id))
|
||||
BEGIN SELECT RAISE(ABORT, 'Assignment audit must match the same immutable Ticket role'); END;
|
||||
CREATE TRIGGER typed_ticket_assignment_operation_parent_insert BEFORE INSERT ON ticket_assignment_operations
|
||||
WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id)
|
||||
BEGIN SELECT RAISE(ABORT, 'New assignment operation requires a Ticket in the same Workspace'); END;
|
||||
|
||||
-- Equality scope keys leave SQLite's implicit rowid as stable append ordering.
|
||||
CREATE INDEX typed_ticket_assignment_tracking_history_scope
|
||||
ON typed_ticket_assignment_tracking_history(workspace_id,ticket_id,assignment_id);
|
||||
@@ -0,0 +1,831 @@
|
||||
use super::*;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
seed_ticket(&conn, "w", "t");
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) {
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap();
|
||||
}
|
||||
|
||||
fn assign(conn: &Connection, id: &str, event: &str) {
|
||||
let previous: Option<String> = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap();
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap();
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
|
||||
fn source() -> TicketAssignmentTrackingSourceContext {
|
||||
TicketAssignmentTrackingSourceContext {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
authentication_binding_id: "binding".into(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
id: &str,
|
||||
event: &str,
|
||||
key: &str,
|
||||
status: TicketAssignmentTrackingStatus,
|
||||
) -> TicketAssignmentTrackingUpdate {
|
||||
TicketAssignmentTrackingUpdate {
|
||||
assignment_id: id.into(),
|
||||
expected_last_event_id: event.into(),
|
||||
operation_key: key.into(),
|
||||
tracking: TicketAssignmentTracking {
|
||||
status,
|
||||
waiting_reason: None,
|
||||
progress: Some("Considering request".into()),
|
||||
next_action: None,
|
||||
result: Some("Returned alternatives".into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn apply(
|
||||
conn: &Connection,
|
||||
input: &TicketAssignmentTrackingUpdate,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<TicketAssignmentTrackingProjection> {
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let result =
|
||||
sqlite_update_ticket_assignment_tracking_in_transaction(conn, "w", "t", input, source);
|
||||
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
|
||||
.unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_initializes_waiting_and_audit_atomically() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(projection.current);
|
||||
assert_eq!(projection.last_event_id.as_deref(), Some("assigned-a"));
|
||||
assert_eq!(
|
||||
projection.tracking.unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].event_id, "assigned-a");
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_done_close_reopen_and_removal_keep_last_result_and_exact_replay() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"done",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
);
|
||||
let result = apply(&conn, &input, &source()).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"open"
|
||||
);
|
||||
conn.execute("UPDATE typed_tickets SET workflow_state='closed'", [])
|
||||
.unwrap();
|
||||
let input2 = update(
|
||||
"a",
|
||||
result.last_event_id.as_deref().unwrap(),
|
||||
"correct-after-close",
|
||||
TicketAssignmentTrackingStatus::Waiting,
|
||||
);
|
||||
let corrected = apply(&conn, &input2, &source()).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"closed"
|
||||
);
|
||||
conn.execute("UPDATE typed_tickets SET workflow_state='open'", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
corrected
|
||||
);
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assert_eq!(apply(&conn, &input, &source()).unwrap(), result);
|
||||
assert_eq!(apply(&conn, &input2, &source()).unwrap(), corrected);
|
||||
assert!(
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"a",
|
||||
corrected.last_event_id.as_deref().unwrap(),
|
||||
"old-new",
|
||||
TicketAssignmentTrackingStatus::Done
|
||||
),
|
||||
&source()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!old.current);
|
||||
assert_eq!(old.tracking, corrected.tracking);
|
||||
let b = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
b.tracking.unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert!(
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"b",
|
||||
"assigned-b",
|
||||
"removed-new",
|
||||
TicketAssignmentTrackingStatus::Done
|
||||
),
|
||||
&source()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
sqlite_current_ticket_assignment_tracking(&conn, "w", "t")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"update",
|
||||
TicketAssignmentTrackingStatus::Inprogress,
|
||||
);
|
||||
let result = apply(&conn, &input, &source()).unwrap();
|
||||
assert_eq!(apply(&conn, &input, &source()).unwrap(), result);
|
||||
let mut changed = input.clone();
|
||||
changed.tracking.result = Some("Different result".into());
|
||||
assert!(apply(&conn, &changed, &source()).is_err());
|
||||
changed = input.clone();
|
||||
changed.operation_key = "fresh-stale".into();
|
||||
assert!(apply(&conn, &changed, &source()).is_err());
|
||||
for change in 0..5 {
|
||||
let mut sender = source();
|
||||
match change {
|
||||
0 => sender.actor = "other".into(),
|
||||
1 => sender.runtime_id = "other".into(),
|
||||
2 => sender.worker_id = "replacement".into(),
|
||||
3 => sender.authentication_binding_id = "replacement-binding".into(),
|
||||
_ => sender.agent_key = "other-agent".into(),
|
||||
};
|
||||
assert!(apply(&conn, &input, &sender).is_err());
|
||||
}
|
||||
let mut other = source();
|
||||
other.worker_id = "replacement".into();
|
||||
// Backend authenticated the replacement as the current stable agent. A new
|
||||
// operation can proceed on the same assignment without starting a new row.
|
||||
let next = update(
|
||||
"a",
|
||||
result.last_event_id.as_deref().unwrap(),
|
||||
"replacement-update",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
);
|
||||
apply(&conn, &next, &other).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "other", "t", "a")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
&conn,
|
||||
"other",
|
||||
"t",
|
||||
&input,
|
||||
&source()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE typed_ticket_assignment_tracking_history SET at='tamper'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"DELETE FROM typed_ticket_assignment_tracking_operations",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_reopens_database_without_restarting_or_reassigning_workers() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ticket.db");
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
seed_ticket(&conn, "w", "t");
|
||||
assign(&conn, "a", "assigned-a");
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"done",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
),
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(
|
||||
sqlite_current_ticket_assignment_tracking(&conn, "w", "t").unwrap()[0]
|
||||
.tracking
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.status,
|
||||
TicketAssignmentTrackingStatus::Done
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-time');").unwrap();
|
||||
install_assignment_core(&conn).unwrap();
|
||||
seed_ticket(&conn, "w", "t");
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let snapshot = TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: original.into(),
|
||||
workflow_state_explicit: true,
|
||||
status: original.into(),
|
||||
queued_by: Some("author".into()),
|
||||
queued_at: Some("original-queue-time".into()),
|
||||
resolution: None,
|
||||
};
|
||||
if original != "no-snapshot" {
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES('w','t',?1)",
|
||||
[serde_json::to_string(&snapshot).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,to_state,at,body) VALUES('w','t',1,'state_changed','queued','original-queue-time','Queue')",[]).unwrap();
|
||||
if fenced {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',1,'orchestrator_assignment_id','a')",[]).unwrap();
|
||||
}
|
||||
if later_case {
|
||||
conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap();
|
||||
}
|
||||
if reassigned {
|
||||
assign(&conn, "b", "assigned-b");
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_preserves_original_records_and_only_reconciles_current_fenced_delegation() {
|
||||
for (original, fenced, later_case, reassigned, expected) in [
|
||||
(
|
||||
"queued",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(TicketAssignmentTrackingStatus::Waiting),
|
||||
),
|
||||
(
|
||||
"inprogress",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(TicketAssignmentTrackingStatus::Inprogress),
|
||||
),
|
||||
("queued", false, false, false, None),
|
||||
("queued", true, true, false, None),
|
||||
("inprogress", true, false, true, None),
|
||||
("ready", true, false, false, None),
|
||||
("done", true, false, false, None),
|
||||
("closed", true, false, false, None),
|
||||
] {
|
||||
let conn = legacy(original, fenced, later_case, reassigned);
|
||||
let original_snapshot: String = conn
|
||||
.query_row(
|
||||
"SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let original_event: String = conn
|
||||
.query_row(
|
||||
"SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT applied_at FROM ticket_schema_migrations WHERE version=10",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"original-time"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
11
|
||||
);
|
||||
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
value.tracking.as_ref().map(|t| t.status),
|
||||
expected,
|
||||
"{original}/{fenced}/{later_case}/{reassigned}"
|
||||
);
|
||||
assert!(value.migration_diagnostic.is_some());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
original_snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
original_event
|
||||
);
|
||||
let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(),
|
||||
history
|
||||
);
|
||||
if reassigned {
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "b")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tracking
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(
|
||||
verify_sqlite_ticket_schema(&conn)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("differs from canonical schema")
|
||||
);
|
||||
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_input_wire_contract_rejects_unknown_fields_and_statuses() {
|
||||
let input = update(
|
||||
"a",
|
||||
"event",
|
||||
"operation",
|
||||
TicketAssignmentTrackingStatus::Inprogress,
|
||||
);
|
||||
let mut value = serde_json::to_value(&input).unwrap();
|
||||
assert_eq!(value["tracking"]["status"], "inprogress");
|
||||
value["claimed_role"] = serde_json::json!("orchestrator");
|
||||
assert!(serde_json::from_value::<TicketAssignmentTrackingUpdate>(value).is_err());
|
||||
let mut value = serde_json::to_value(&input).unwrap();
|
||||
value["tracking"]["status"] = serde_json::json!("running");
|
||||
assert!(serde_json::from_value::<TicketAssignmentTrackingUpdate>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_pending_claims_can_finish()
|
||||
{
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap();
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignments SET assigned_at='forged-time' WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignments SET role='owner',principal_kind='user',principal_id='user' WHERE assignment_id='a'",
|
||||
"DELETE FROM ticket_worker_assignments WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignment_events SET actor='forged'",
|
||||
"UPDATE ticket_worker_assignment_events SET event_id='forged'",
|
||||
"DELETE FROM ticket_worker_assignment_events",
|
||||
"UPDATE ticket_assignment_operations SET request_fingerprint='forged' WHERE operation_id='assigned-op'",
|
||||
"UPDATE ticket_assignment_operations SET created_at='forged' WHERE operation_id='assigned-op'",
|
||||
"DELETE FROM ticket_assignment_operations WHERE operation_id='assigned-op'",
|
||||
"UPDATE ticket_current_worker_assignments SET principal_id='another-agent'",
|
||||
"UPDATE ticket_worker_assignments SET tracking_json='{}' WHERE assignment_id='a'",
|
||||
] {
|
||||
assert!(
|
||||
conn.execute(sql, []).is_err(),
|
||||
"deny direct mutation: {sql}"
|
||||
);
|
||||
}
|
||||
// The immutable original principal also fences insertion of a mismatched
|
||||
// current row, even in a standalone Ticket store without host constraints.
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err());
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap();
|
||||
assert!(conn.execute("UPDATE ticket_assignment_operations SET failure_reason='rewrite' WHERE operation_id='pending'",[]).is_err());
|
||||
assert!(
|
||||
conn.execute(
|
||||
"DELETE FROM ticket_assignment_operations WHERE operation_id='pending'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let result = apply(
|
||||
&conn,
|
||||
&update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"result",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
),
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assign(&conn, "b", "assigned-b");
|
||||
let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let release = old.release.as_ref().unwrap();
|
||||
assert_eq!(release.action, TicketAssignmentReleaseAction::Replaced);
|
||||
assert_eq!(release.replacement_assignment_id.as_deref(), Some("b"));
|
||||
assert_eq!(release.last_tracking, result.tracking);
|
||||
assert_eq!(release.actor, "author");
|
||||
assert_eq!(release.event_id, "assigned-b");
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id,reason) VALUES('w','t','orchestrator','unassign-b','unassigned','b','human','original-unassign-time','unassign-op','Withdrawn')",[]).unwrap();
|
||||
let removed = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let release = removed.release.unwrap();
|
||||
assert_eq!(release.action, TicketAssignmentReleaseAction::Unassigned);
|
||||
assert_eq!(release.at, "original-unassign-time");
|
||||
assert_eq!(release.reason.as_deref(), Some("Withdrawn"));
|
||||
assert_eq!(
|
||||
release.last_tracking.as_ref().unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
let legacy = legacy("done", true, false, false);
|
||||
migrate_sqlite_ticket_schema(&legacy).unwrap();
|
||||
legacy
|
||||
.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
legacy.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id) VALUES('w','t','orchestrator','unassign-legacy','unassigned','a','human','legacy-unassign-time','legacy-unassign-op')",[]).unwrap();
|
||||
let original = sqlite_ticket_assignment_tracking(&legacy, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(original.tracking.is_none());
|
||||
assert!(original.release.unwrap().last_tracking.is_none());
|
||||
}
|
||||
|
||||
fn routing_queue(conn: &Connection, index: i64, fence: &str) {
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,from_state,to_state,at,state_field,reason,body) VALUES('w','t',?1,'state_changed','open','open','typed-queue-time','routing','queued','Explicit component-10 Queue')",[index]).unwrap();
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'orchestrator_assignment_id',?2)",params![index,fence]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_recognizes_typed_case_neutral_queue_and_never_revives_an_older_mismatched_queue() {
|
||||
for original in ["ready", "no-snapshot"] {
|
||||
let conn = legacy(original, true, false, false);
|
||||
routing_queue(&conn, 2, "a");
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
assignment.tracking.as_ref().unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert_eq!(assignment.updated_at.as_deref(), Some("typed-queue-time"));
|
||||
}
|
||||
let conn = legacy("queued", true, false, false);
|
||||
routing_queue(&conn, 2, "mismatched-assignment");
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tracking
|
||||
.is_none()
|
||||
);
|
||||
let conn = legacy("no-snapshot", true, false, false);
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.tracking
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_accepts_new_explicit_queue_after_ordered_case_judgments_but_not_after_a_later_case() {
|
||||
for queue_after_case in [true, false] {
|
||||
let conn = legacy("queued", true, false, false);
|
||||
for (ordinal, index, action, state) in [
|
||||
(
|
||||
0,
|
||||
2,
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
},
|
||||
"closed",
|
||||
),
|
||||
(1, 3, TicketCaseAction::Reopen, "open"),
|
||||
] {
|
||||
let key = format!("case-{ordinal}");
|
||||
let judgment = TicketCaseHistoryEntry {
|
||||
operation_key: key.clone(),
|
||||
previous_closure_operation_key: if ordinal == 1 {
|
||||
Some("case-0".into())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
action,
|
||||
explanation: "Original case decision".into(),
|
||||
references: vec![],
|
||||
author: "human".into(),
|
||||
at: format!("case-time-{ordinal}"),
|
||||
};
|
||||
conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',?1,?2,'original-fingerprint',?3)",params![ordinal,key,serde_json::to_string(&judgment).unwrap()]).unwrap();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,state_field,to_state,at,body) VALUES('w','t',?1,'state_changed','case',?2,?3,'Original case decision')",params![index,state,judgment.at]).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'operation_key',?2)",
|
||||
params![index, key],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if queue_after_case {
|
||||
routing_queue(&conn, 4, "a");
|
||||
}
|
||||
let original_history: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(assignment.tracking.is_some(), queue_after_case);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
original_history
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_audit_and_operations()
|
||||
{
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
for sql in [
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_worker_assignment_events",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals() {
|
||||
let conn = db();
|
||||
let sql:String=conn.query_row("SELECT sql FROM sqlite_schema WHERE name='typed_ticket_assignment_orchestrator_principal_insert'",[],|r|r.get(0)).unwrap();
|
||||
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert")
|
||||
.unwrap();
|
||||
conn.execute_batch(&sql.replace("NEW.role='orchestrator'", "NEW.role='Orchestrator'"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
verify_sqlite_ticket_schema(&conn)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("differs from canonical schema")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let answer = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Continue with B".into(),
|
||||
};
|
||||
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
|
||||
let apply = |answer: &TicketAssignmentAnswer| {
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let result = sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn, "w", "t", answer, &source, "human",
|
||||
);
|
||||
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
|
||||
.unwrap();
|
||||
result
|
||||
};
|
||||
let result = apply(&answer).unwrap();
|
||||
assert_eq!(result.input_event_id, "ticket-event:0");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
answer.body
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_event_id
|
||||
.as_deref(),
|
||||
Some("assigned-a")
|
||||
);
|
||||
let mut altered = answer.clone();
|
||||
altered.body = "Changed".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
altered.operation_key = "fresh-stale".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let original = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Answer".into(),
|
||||
};
|
||||
for field in [
|
||||
"body-large",
|
||||
"body-nul",
|
||||
"body-empty",
|
||||
"identity-control",
|
||||
"event-empty",
|
||||
] {
|
||||
let mut answer = original.clone();
|
||||
match field {
|
||||
"body-large" => answer.body = "a".repeat(16385),
|
||||
"body-nul" => answer.body = "answer\0hidden".into(),
|
||||
"body-empty" => answer.body = " ".into(),
|
||||
"identity-control" => answer.operation_key = "key\n".into(),
|
||||
_ => answer.expected_last_event_id = "".into(),
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn,
|
||||
"w",
|
||||
"t",
|
||||
&answer,
|
||||
&serde_json::json!({"actor":"source"}),
|
||||
"human"
|
||||
)
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
@@ -131,3 +131,73 @@ CREATE INDEX typed_tickets_workspace_title
|
||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_updated
|
||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
|
||||
CREATE TABLE workspace_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
last_operation_key TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
|
||||
PRIMARY KEY (workspace_id, tag_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_tags_active_name
|
||||
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
|
||||
CREATE TABLE typed_ticket_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, tag_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX typed_ticket_tags_workspace_tag
|
||||
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
|
||||
-- Immutable operation receipt and Workspace audit: exact input includes actor
|
||||
-- attributes; before/result preserve stable identities and display-name snapshots.
|
||||
-- No foreign keys to mutable rows: history survives deletion of current links.
|
||||
CREATE TABLE workspace_tag_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
input_json TEXT NOT NULL,
|
||||
source_context_json TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_key)
|
||||
);
|
||||
|
||||
+588
-2034
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,16 @@
|
||||
//! Ticket schema authority is the verified persisted shape, not a version marker alone.
|
||||
//! Startup migrations compose case, tag, and assignment schemas without replacing applied
|
||||
//! migrations; request paths verify only and never acquire DDL authority.
|
||||
//!
|
||||
//! Legacy planning/ready/queued/inprogress become open; done/closed become closed with
|
||||
//! an explicit migration actor and unknown reason, never inferred success. Immutable
|
||||
//! snapshots retain the old phase, flags, routing, and resolution for downstream handoff,
|
||||
//! not a second current case authority. Existing tags, tombstones, memberships, receipts,
|
||||
//! judgments, and audit snapshots survive composition and are not regenerated on rerun.
|
||||
//! Assignment migration preserves identities and reconciled evidence; ambiguous or
|
||||
//! historical relationships remain diagnostic unknown handling, not fresh execution input.
|
||||
//! Migration never starts Workers or restores execution grants.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -7,23 +20,9 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
|
||||
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11;
|
||||
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Migration {
|
||||
version: i64,
|
||||
name: &'static str,
|
||||
apply: fn(&Connection) -> Result<()>,
|
||||
}
|
||||
|
||||
const MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
name: BASELINE_MIGRATION_NAME,
|
||||
apply: create_latest_ticket_schema,
|
||||
}];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ExpectedColumn {
|
||||
name: &'static str,
|
||||
@@ -41,6 +40,9 @@ struct ExpectedForeignKey {
|
||||
}
|
||||
|
||||
const OWNED_TABLES: &[&str] = &[
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_labels",
|
||||
@@ -52,6 +54,9 @@ const OWNED_TABLES: &[&str] = &[
|
||||
"typed_ticket_relations",
|
||||
"typed_ticket_orchestration_plans",
|
||||
"typed_ticket_artifacts",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"workspace_resource_keys",
|
||||
];
|
||||
|
||||
@@ -96,6 +101,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("resolution", "TEXT", false, 0),
|
||||
];
|
||||
|
||||
const TAG_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("tag_id", "TEXT", true, 2),
|
||||
column("name", "TEXT", true, 0),
|
||||
column("normalized_name", "TEXT", true, 0),
|
||||
column("last_operation_key", "TEXT", true, 0),
|
||||
column("deleted", "INTEGER", true, 0),
|
||||
];
|
||||
const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("tag_id", "TEXT", true, 3),
|
||||
];
|
||||
const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("operation_key", "TEXT", true, 2),
|
||||
column("kind", "TEXT", true, 0),
|
||||
column("target_id", "TEXT", true, 0),
|
||||
column("input_json", "TEXT", true, 0),
|
||||
column("source_context_json", "TEXT", true, 0),
|
||||
column("before_json", "TEXT", true, 0),
|
||||
column("result_json", "TEXT", true, 0),
|
||||
column("at", "TEXT", true, 0),
|
||||
];
|
||||
const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[
|
||||
ExpectedForeignKey {
|
||||
from: "workspace_id",
|
||||
target_table: "typed_tickets",
|
||||
to: "workspace_id",
|
||||
on_delete: "CASCADE",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "ticket_id",
|
||||
target_table: "typed_tickets",
|
||||
to: "ticket_id",
|
||||
on_delete: "CASCADE",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "workspace_id",
|
||||
target_table: "workspace_tags",
|
||||
to: "workspace_id",
|
||||
on_delete: "RESTRICT",
|
||||
},
|
||||
ExpectedForeignKey {
|
||||
from: "tag_id",
|
||||
target_table: "workspace_tags",
|
||||
to: "tag_id",
|
||||
on_delete: "RESTRICT",
|
||||
},
|
||||
];
|
||||
|
||||
const TARGET_COLUMNS: &[ExpectedColumn] = &[
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
@@ -254,66 +310,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(sqlite_err)?;
|
||||
|
||||
let result = (|| {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
let migration = MIGRATIONS
|
||||
.first()
|
||||
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?;
|
||||
(migration.apply)(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
migration.version,
|
||||
migration.name,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else if applied
|
||||
== BTreeMap::from([(
|
||||
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
BASELINE_MIGRATION_NAME.to_string(),
|
||||
)])
|
||||
{
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
|
||||
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
BASELINE_MIGRATION_NAME,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else {
|
||||
validate_applied_migrations(&applied)?;
|
||||
}
|
||||
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
})();
|
||||
|
||||
let result = migrate_sqlite_ticket_schema_in_transaction(connection);
|
||||
match result {
|
||||
Ok(()) => connection.execute_batch("COMMIT").map_err(sqlite_err),
|
||||
Err(error) => {
|
||||
@@ -323,8 +320,170 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then
|
||||
/// appends the 10→11 assignment migration without replacing the applied 10 marker.
|
||||
/// The host owns the surrounding transaction (for example Workspace 91→92).
|
||||
/// A savepoint rolls back this component on failure, without committing host work.
|
||||
/// Current component 11 is verified only; immutable judgments are never reapplied.
|
||||
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
|
||||
if connection.is_autocommit() {
|
||||
return Err(TicketError::Sqlite(
|
||||
"Ticket component migration requires a host transaction".into(),
|
||||
));
|
||||
}
|
||||
connection
|
||||
.execute_batch("SAVEPOINT ticket_component_composition")
|
||||
.map_err(sqlite_err)?;
|
||||
let result = (|| {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
|
||||
version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
create_latest_ticket_schema(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations VALUES (11, ?1, ?2)",
|
||||
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else {
|
||||
let version = if applied
|
||||
== BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
]) {
|
||||
11
|
||||
} else {
|
||||
match applied.iter().next() {
|
||||
Some((&version, name))
|
||||
if applied.len() == 1
|
||||
&& name == BASELINE_MIGRATION_NAME
|
||||
&& matches!(version, 6..=11) =>
|
||||
{
|
||||
version
|
||||
}
|
||||
_ => {
|
||||
validate_applied_migrations(&applied)?;
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
};
|
||||
if version == 6 {
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
|
||||
[],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
|
||||
verify_ticket_schema_shape(connection, shape)?;
|
||||
match shape {
|
||||
TicketSchemaShape::Legacy7 => {
|
||||
add_tag_schema(connection)?;
|
||||
migrate_legacy_cases(connection)?;
|
||||
}
|
||||
TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?,
|
||||
TicketSchemaShape::Cases9 => add_tag_schema(connection)?,
|
||||
TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {}
|
||||
}
|
||||
if version < 10 {
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
|
||||
params![chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
if version < 11 {
|
||||
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
|
||||
connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
|
||||
}
|
||||
}
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => connection
|
||||
.execute_batch("RELEASE ticket_component_composition")
|
||||
.map_err(sqlite_err),
|
||||
Err(error) => {
|
||||
let _ = connection.execute_batch(
|
||||
"ROLLBACK TO ticket_component_composition; RELEASE ticket_component_composition",
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TicketSchemaShape {
|
||||
Legacy7,
|
||||
Tags8,
|
||||
Cases9,
|
||||
Composed10,
|
||||
Tracking11,
|
||||
}
|
||||
impl TicketSchemaShape {
|
||||
fn from_version(version: i64) -> Self {
|
||||
match version {
|
||||
7 => Self::Legacy7,
|
||||
8 => Self::Tags8,
|
||||
9 => Self::Cases9,
|
||||
10 => Self::Composed10,
|
||||
11 => Self::Tracking11,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
fn version(self) -> i64 {
|
||||
match self {
|
||||
Self::Legacy7 => 7,
|
||||
Self::Tags8 => 8,
|
||||
Self::Cases9 => 9,
|
||||
Self::Composed10 => 10,
|
||||
Self::Tracking11 => 11,
|
||||
}
|
||||
}
|
||||
fn tags(self) -> bool {
|
||||
matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11)
|
||||
}
|
||||
fn cases(self) -> bool {
|
||||
matches!(self, Self::Cases9 | Self::Composed10 | Self::Tracking11)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
||||
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11)
|
||||
}
|
||||
|
||||
/// Retained Workspace schema-66 migration produces the pre-classification v7
|
||||
/// Ticket shape. Validate that intermediate contract before the normal startup
|
||||
/// path upgrades it; never require future tag tables inside that old transaction.
|
||||
pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Legacy7)
|
||||
}
|
||||
|
||||
/// Verifies retained six-state component 7 or tags-only component 8, without
|
||||
/// treating historical workflow values as the current two-state case authority.
|
||||
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
let version = applied.keys().next().copied().unwrap_or_default();
|
||||
if !matches!(version, 7 | 8) {
|
||||
return Err(TicketError::Sqlite(
|
||||
"legacy Ticket schema requires component 7 or 8".into(),
|
||||
));
|
||||
}
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version))
|
||||
}
|
||||
|
||||
fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) -> Result<()> {
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
collect_table_diagnostics(
|
||||
@@ -337,23 +496,40 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
|
||||
match load_applied_migrations(connection) {
|
||||
Ok(applied) => {
|
||||
if let Err(error) = validate_applied_migrations(&applied) {
|
||||
let history = if applied
|
||||
== BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())])
|
||||
|| (matches!(shape, TicketSchemaShape::Tracking11)
|
||||
&& applied
|
||||
== BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
])) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TicketError::Sqlite(format!(
|
||||
"Ticket schema migration history must contain only the canonical version {} baseline marker",
|
||||
shape.version()
|
||||
)))
|
||||
};
|
||||
if let Err(error) = history {
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
} else if applied.len() != MIGRATIONS.len() {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!(
|
||||
"Ticket schema is not current: found {} migration(s), expected {}",
|
||||
applied.len(),
|
||||
MIGRATIONS.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => push_diagnostic(&mut diagnostics, error.to_string()),
|
||||
}
|
||||
|
||||
for (table, columns, foreign_keys) in [
|
||||
("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS),
|
||||
(
|
||||
"typed_ticket_tags",
|
||||
TICKET_TAG_COLUMNS,
|
||||
TICKET_TAG_FOREIGN_KEYS,
|
||||
),
|
||||
(
|
||||
"workspace_tag_operations",
|
||||
TAG_OPERATION_COLUMNS,
|
||||
TICKET_FOREIGN_KEYS,
|
||||
),
|
||||
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
|
||||
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
|
||||
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
|
||||
@@ -394,9 +570,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
CHILD_FOREIGN_KEYS,
|
||||
),
|
||||
] {
|
||||
if !shape.tags()
|
||||
&& matches!(
|
||||
table,
|
||||
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
|
||||
}
|
||||
|
||||
if shape.tags() {
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"workspace_tags",
|
||||
Some("workspace_tags_active_name"),
|
||||
true,
|
||||
&["workspace_id", "normalized_name"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_tags",
|
||||
Some("typed_ticket_tags_workspace_tag"),
|
||||
false,
|
||||
&["workspace_id", "tag_id", "ticket_id"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
let tag_name_index_sql: Option<String> = connection.query_row("SELECT sql FROM sqlite_master WHERE type='index' AND name='workspace_tags_active_name'", [], |row| row.get(0)).optional().map_err(sqlite_err)?;
|
||||
if !tag_name_index_sql.is_some_and(|sql| {
|
||||
sql.split_whitespace()
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
.ends_with("wheredeleted=0")
|
||||
}) {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
"workspace_tags_active_name must be partial WHERE deleted = 0".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_targets",
|
||||
@@ -448,10 +662,154 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
|
||||
}
|
||||
|
||||
if shape.cases() {
|
||||
for (table, columns) in [
|
||||
(
|
||||
"typed_ticket_case_metadata",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("closure_operation_key", "TEXT", false, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_case_history",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("ordinal", "INTEGER", true, 0),
|
||||
column("operation_key", "TEXT", true, 3),
|
||||
column("request_fingerprint", "TEXT", true, 0),
|
||||
column("judgment_json", "TEXT", true, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
vec![
|
||||
column("workspace_id", "TEXT", true, 1),
|
||||
column("ticket_id", "TEXT", true, 2),
|
||||
column("snapshot_json", "TEXT", true, 0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false)
|
||||
{
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
}
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
"typed_ticket_case_history",
|
||||
None,
|
||||
true,
|
||||
&["workspace_id", "ticket_id", "ordinal"],
|
||||
&mut diagnostics,
|
||||
);
|
||||
for name in [
|
||||
"typed_ticket_case_history_no_update",
|
||||
"typed_ticket_case_history_no_delete",
|
||||
"typed_ticket_legacy_workflow_no_update",
|
||||
"typed_ticket_legacy_workflow_no_delete",
|
||||
"typed_ticket_case_state_insert",
|
||||
"typed_ticket_case_state_update",
|
||||
] {
|
||||
let sql: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?;
|
||||
let canonical = include_str!("case_schema.sql")
|
||||
.split("CREATE TRIGGER ")
|
||||
.skip(1)
|
||||
.find(|ddl| ddl.starts_with(name))
|
||||
.expect("canonical case trigger")
|
||||
.split_once("END;")
|
||||
.expect("trigger terminator")
|
||||
.0;
|
||||
let canonical = format!("CREATE TRIGGER {canonical}END");
|
||||
let normalize = |sql: &str| {
|
||||
sql.trim_end_matches(';')
|
||||
.split_whitespace()
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
};
|
||||
match sql {
|
||||
None => push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!("missing Ticket case trigger {name}"),
|
||||
),
|
||||
Some(sql) if normalize(&sql) != normalize(&canonical) => push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!("invalid Ticket case trigger {name}"),
|
||||
),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for table in [
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
] {
|
||||
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
|
||||
}
|
||||
}
|
||||
if !shape.tags() {
|
||||
for table in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
] {
|
||||
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
|
||||
}
|
||||
}
|
||||
let invalid_states: i64 = connection.query_row(
|
||||
if shape.cases() {
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open','closed')"
|
||||
} else {
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('planning','ready','queued','inprogress','done','closed')"
|
||||
}, [], |row| row.get(0)).map_err(sqlite_err)?;
|
||||
if invalid_states != 0 {
|
||||
push_diagnostic(
|
||||
&mut diagnostics,
|
||||
format!(
|
||||
"component {} has {invalid_states} unsupported Ticket states",
|
||||
shape.version()
|
||||
),
|
||||
);
|
||||
}
|
||||
for table in OWNED_TABLES {
|
||||
if !shape.tags()
|
||||
&& matches!(
|
||||
*table,
|
||||
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !shape.cases()
|
||||
&& matches!(
|
||||
*table,
|
||||
"typed_ticket_case_metadata"
|
||||
| "typed_ticket_case_history"
|
||||
| "typed_ticket_legacy_workflow_snapshot"
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
|
||||
}
|
||||
|
||||
if matches!(shape, TicketSchemaShape::Tracking11) {
|
||||
if let Err(error) =
|
||||
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
|
||||
{
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
}
|
||||
if diagnostics.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -471,7 +829,93 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(include_str!("latest_schema.sql"))
|
||||
.map_err(sqlite_err)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::assignment_tracking::migrate_assignment_tracking(connection)
|
||||
}
|
||||
|
||||
/// Retained 7/8 case migration: leave tags and historical receipts untouched.
|
||||
fn migrate_legacy_cases(connection: &Connection) -> Result<()> {
|
||||
let snapshots = {
|
||||
let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?;
|
||||
stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
crate::TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: row.get(2)?,
|
||||
workflow_state_explicit: row.get::<_, i64>(3)? != 0,
|
||||
status: row.get(4)?,
|
||||
queued_by: row.get(5)?,
|
||||
queued_at: row.get(6)?,
|
||||
resolution: row.get(7)?,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?
|
||||
};
|
||||
// Validate old authority before any writes; unknown states are not silently repaired.
|
||||
for (_, _, snapshot) in &snapshots {
|
||||
if !matches!(
|
||||
snapshot.workflow_state.as_str(),
|
||||
"planning" | "ready" | "queued" | "inprogress" | "done" | "closed"
|
||||
) {
|
||||
return Err(TicketError::Sqlite(format!(
|
||||
"unsupported legacy Ticket workflow state {:?}",
|
||||
snapshot.workflow_state
|
||||
)));
|
||||
}
|
||||
}
|
||||
// State guards are installed only after all six-state rows have been converted.
|
||||
for (workspace_id, ticket_id, snapshot) in &snapshots {
|
||||
let state = if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
connection.execute("UPDATE typed_tickets SET workflow_state = ?3, status = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![workspace_id, ticket_id, state]).map_err(sqlite_err)?;
|
||||
}
|
||||
connection
|
||||
.execute_batch(include_str!("case_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
for (workspace_id, ticket_id, snapshot) in snapshots {
|
||||
connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
|
||||
let key = format!("migration-v10:{ticket_id}");
|
||||
let judgment = crate::TicketCaseHistoryEntry {
|
||||
operation_key: key.clone(),
|
||||
previous_closure_operation_key: None,
|
||||
// No typed evidence in schema7 establishes Completed. `done` is not success.
|
||||
action: crate::TicketCaseAction::Close {
|
||||
reason: crate::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: snapshot
|
||||
.resolution
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Migrated legacy {} state; closure reason is unknown.",
|
||||
snapshot.workflow_state
|
||||
)
|
||||
}),
|
||||
references: Vec::new(),
|
||||
author: "ticket-schema-migration-v10".into(),
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v10', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn add_tag_schema(connection: &Connection) -> Result<()> {
|
||||
// Use the exact canonical DDL rather than duplicating migration definitions.
|
||||
let (_, tag_schema) = include_str!("latest_schema.sql")
|
||||
.split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.")
|
||||
.expect("canonical tag schema marker");
|
||||
connection.execute_batch(tag_schema).map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
|
||||
@@ -636,9 +1080,15 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
|
||||
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
|
||||
let expected = BTreeMap::from([(
|
||||
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
|
||||
MIGRATIONS[0].name.to_string(),
|
||||
BASELINE_MIGRATION_NAME.to_string(),
|
||||
)]);
|
||||
if applied == &expected {
|
||||
if applied == &expected
|
||||
|| applied
|
||||
== &BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
])
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TicketError::Sqlite(format!(
|
||||
@@ -1026,7 +1476,9 @@ mod tests {
|
||||
#[test]
|
||||
fn migrates_single_target_rows_to_ordered_read_write_targets() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
create_latest_ticket_schema(&connection).unwrap();
|
||||
connection
|
||||
.execute_batch(include_str!("../tests/fixtures/schema7.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"DROP TABLE typed_ticket_targets;
|
||||
@@ -1137,10 +1589,137 @@ mod tests {
|
||||
assert!(!migrated_attributes.contains_key("ref_selector"));
|
||||
assert_eq!(
|
||||
load_applied_migrations(&connection).unwrap(),
|
||||
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
|
||||
BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned())
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() {
|
||||
let upgraded = Connection::open_in_memory().unwrap();
|
||||
let legacy_sql = include_str!("../tests/fixtures/schema7.sql");
|
||||
upgraded.execute_batch(legacy_sql).unwrap();
|
||||
upgraded.execute_batch(
|
||||
"CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old');
|
||||
INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1);
|
||||
INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');"
|
||||
).unwrap();
|
||||
migrate_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
verify_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
let fresh = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&fresh).unwrap();
|
||||
let schema = |conn: &Connection| {
|
||||
let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap();
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?
|
||||
.map(|sql| sql.split_whitespace().collect::<String>()),
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(schema(&upgraded), schema(&fresh));
|
||||
let label: String = upgraded
|
||||
.query_row("SELECT label FROM typed_ticket_labels", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(label, " Legacy Label ");
|
||||
let tags: i64 = upgraded
|
||||
.query_row("SELECT COUNT(*) FROM workspace_tags", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0); // Legacy labels are never promoted to catalog tags.
|
||||
migrate_sqlite_ticket_schema(&upgraded).unwrap();
|
||||
assert_eq!(schema(&upgraded), schema(&fresh));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_schema_verification_detects_drift_without_repair() {
|
||||
for (drift, expected) in [
|
||||
("DROP TABLE typed_ticket_tags", "typed_ticket_tags"),
|
||||
(
|
||||
"ALTER TABLE workspace_tags RENAME COLUMN last_operation_key TO wrong_observed_key",
|
||||
"last_operation_key",
|
||||
),
|
||||
(
|
||||
"ALTER TABLE workspace_tag_operations DROP COLUMN result_json",
|
||||
"result_json",
|
||||
),
|
||||
(
|
||||
"DROP INDEX typed_ticket_tags_workspace_tag",
|
||||
"typed_ticket_tags_workspace_tag",
|
||||
),
|
||||
(
|
||||
"DROP INDEX workspace_tags_active_name; CREATE UNIQUE INDEX workspace_tags_active_name ON workspace_tags(workspace_id, normalized_name)",
|
||||
"partial WHERE deleted = 0",
|
||||
),
|
||||
(
|
||||
"DROP TABLE typed_ticket_tags; CREATE TABLE typed_ticket_tags (workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,tag_id TEXT NOT NULL, PRIMARY KEY(workspace_id,ticket_id,tag_id)); CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id)",
|
||||
"missing foreign key",
|
||||
),
|
||||
] {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
connection.execute_batch(drift).unwrap();
|
||||
let error = verify_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected),
|
||||
"after {drift}: {error}"
|
||||
);
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_schema_rejects_cross_workspace_links_and_preserves_tombstones() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
connection.execute_batch(
|
||||
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1);
|
||||
INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);"
|
||||
).unwrap();
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
connection
|
||||
.execute("DELETE FROM workspace_tags WHERE workspace_id='one'", [])
|
||||
.is_err()
|
||||
);
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_future_migration_history_without_changing_schema() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
@@ -1155,7 +1734,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain only the canonical version 7 baseline marker"
|
||||
"migration history must contain only the canonical version 11 baseline marker"
|
||||
));
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1177,7 +1756,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain only the canonical version 7 baseline marker"
|
||||
"migration history must contain only the canonical version 11 baseline marker"
|
||||
));
|
||||
assert!(!table_exists(&connection, "typed_tickets").unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Workspace tag identity, display-name policy, and transactional SQLite authority.
|
||||
//!
|
||||
//! Display names use Rust `str::trim` (Unicode whitespace), then Unicode
|
||||
//! `str::to_lowercase` for active-name uniqueness. No NFC/NFKC normalization or
|
||||
//! full case folding is performed: canonically equivalent spellings can differ.
|
||||
//! Any control character in the input is rejected, including trimmed edges.
|
||||
//! The trimmed display name must be nonempty and at most 128 UTF-8 bytes.
|
||||
//! IDs are immutable and Workspace-scoped; names never grant execution authority.
|
||||
//! Rename preserves memberships. Delete atomically tombstones the identity and removes
|
||||
//! current memberships, retaining historical identity/name snapshots. Reusing a deleted
|
||||
//! name creates a new ID. Legacy labels are not classification authority.
|
||||
//!
|
||||
//! Rename/delete compare the observed tag operation key; membership replacement compares
|
||||
//! the full active ID set, not a content digest or timestamp. Returning to the same set is
|
||||
//! intentionally equivalent; an observed deleted ID is stale. Delete and attachment share
|
||||
//! an immediate transaction, so missing, deleted, or foreign identities cannot dangle.
|
||||
//!
|
||||
//! Exact replay compares full input, target, and authenticated identity before live checks,
|
||||
//! returning the saved response without another audit event. It is not fresh observation:
|
||||
//! reread before a new edit, and retain the original key/input after an uncertain outcome.
|
||||
//! A no-op set saves a receipt without a tags-changed event. Tag edits change neither
|
||||
//! editable requirements nor case judgments, handling, grants, routing, or MR evidence.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tags_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Workspace-scoped immutable identity with a mutable display name.
|
||||
/// Names are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and
|
||||
/// deduplicated among active tags using Rust Unicode lowercase (not full case
|
||||
/// folding or NFC/NFKC). Empty names and any input control characters are invalid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct WorkspaceTag {
|
||||
pub workspace_id: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub last_operation_key: String,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// Active tags in ascending ID order, strictly after `after`; default/max 200.
|
||||
/// Larger limits are clamped to 200; zero returns an empty page.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TagListRequest {
|
||||
pub after: Option<String>,
|
||||
#[schemars(range(min = 0, max = 200))]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceTagMutation {
|
||||
pub operation_key: String,
|
||||
pub action: WorkspaceTagAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkspaceTagAction {
|
||||
Create {
|
||||
name: String,
|
||||
},
|
||||
Rename {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Compare entire active ID set, then replace it atomically. IDs must be unique;
|
||||
/// input order is immaterial to CAS, but remains significant for exact replay.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketTagSetRequest {
|
||||
pub expected_tag_ids: Vec<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub operation_key: String,
|
||||
}
|
||||
|
||||
fn normalize_name(input: &str) -> Result<(String, String)> {
|
||||
let name = input.trim();
|
||||
if name.is_empty() || name.len() > 128 || input.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict("tag name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
|
||||
}
|
||||
Ok((name.to_owned(), name.to_lowercase()))
|
||||
}
|
||||
|
||||
fn sorted_ids(ids: &[String]) -> Result<Vec<String>> {
|
||||
if ids.len() > 100 || ids.iter().any(|id| id.len() > 128) {
|
||||
return Err(TicketError::Conflict(
|
||||
"tag sets require at most 100 IDs of at most 128 bytes".into(),
|
||||
));
|
||||
}
|
||||
let mut sorted = ids.to_vec();
|
||||
sorted.sort();
|
||||
if sorted.iter().any(|id| id.trim().is_empty())
|
||||
|| sorted.windows(2).any(|pair| pair[0] == pair[1])
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tag IDs must be nonempty and unique".into(),
|
||||
));
|
||||
}
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
fn read_tag(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceTag> {
|
||||
Ok(WorkspaceTag {
|
||||
workspace_id: row.get(0)?,
|
||||
id: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
last_operation_key: row.get(3)?,
|
||||
deleted: row.get::<_, i64>(4)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
impl SqliteTicketBackend {
|
||||
pub(crate) fn load_ticket_tags(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<WorkspaceTag>> {
|
||||
let mut statement = conn.prepare("SELECT tag.workspace_id,tag.tag_id,tag.name,tag.last_operation_key,tag.deleted FROM workspace_tags AS tag JOIN typed_ticket_tags AS link ON link.workspace_id=tag.workspace_id AND link.tag_id=tag.tag_id WHERE link.workspace_id=?1 AND link.ticket_id=?2 AND tag.deleted=0 ORDER BY tag.tag_id").map_err(sqlite_err)?;
|
||||
statement
|
||||
.query_map(params![self.workspace_id, ticket_id], read_tag)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn load_tag(&self, conn: &Connection, id: &str) -> Result<WorkspaceTag> {
|
||||
conn.query_row("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2", params![self.workspace_id,id], read_tag).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(format!("workspace tag {id}")))
|
||||
}
|
||||
|
||||
fn tag_input(&self, kind: &str, target: &str, request: &impl Serialize) -> Result<String> {
|
||||
// Bind authenticated identity, not its mutable assignment/role context:
|
||||
// a receipt is still the same request after the Worker is reassigned.
|
||||
// The complete original context is retained separately in the audit row.
|
||||
let identity: BTreeMap<_, _> = self
|
||||
.event_attributes
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"source_account_id"
|
||||
| "source_user_id"
|
||||
| "source_runtime_id"
|
||||
| "source_worker_id"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&(kind, target, request, identity)).map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn replay_tag_operation<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
input: &str,
|
||||
) -> Result<Option<T>> {
|
||||
validate_required_event_value("operation_key", key)?;
|
||||
let receipt: Option<(String,String)> = conn.query_row("SELECT input_json,result_json FROM workspace_tag_operations WHERE workspace_id=?1 AND operation_key=?2",params![self.workspace_id,key],|row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(sqlite_err)?;
|
||||
match receipt {
|
||||
Some((original, result)) if original == input => {
|
||||
Ok(Some(serde_json::from_str(&result).map_err(sqlite_err)?))
|
||||
}
|
||||
Some(_) => Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: key.to_owned(),
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tag_operation(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
kind: &str,
|
||||
target: &str,
|
||||
input: &str,
|
||||
snapshots: (&impl Serialize, &impl Serialize),
|
||||
) -> Result<()> {
|
||||
conn.execute("INSERT INTO workspace_tag_operations (workspace_id,operation_key,kind,target_id,input_json,before_json,result_json,at,source_context_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![self.workspace_id,key,kind,target,input,serde_json::to_string(snapshots.0).map_err(sqlite_err)?,serde_json::to_string(snapshots.1).map_err(sqlite_err)?,now_utc(),serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?]).map_err(sqlite_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audit_ticket_tags(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
ticket_id: &str,
|
||||
key: &str,
|
||||
before: &[WorkspaceTag],
|
||||
after: &[WorkspaceTag],
|
||||
) -> Result<()> {
|
||||
self.insert_event(
|
||||
conn,
|
||||
ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::TagsChanged,
|
||||
author: Some(default_author()),
|
||||
at: Some(now_utc()),
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some("Tags changed".into()),
|
||||
body: MarkdownText::new("Ticket tags changed"),
|
||||
references: Vec::new(),
|
||||
attributes: BTreeMap::from([
|
||||
("operation_key".into(), key.to_owned()),
|
||||
(
|
||||
"tags_before".into(),
|
||||
serde_json::to_string(before).map_err(sqlite_err)?,
|
||||
),
|
||||
(
|
||||
"tags_after".into(),
|
||||
serde_json::to_string(after).map_err(sqlite_err)?,
|
||||
),
|
||||
]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
|
||||
self.with_read(|conn| {
|
||||
let mut statement = conn.prepare("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND deleted=0 AND (?2 IS NULL OR tag_id>?2) ORDER BY tag_id LIMIT ?3").map_err(sqlite_err)?;
|
||||
statement.query_map(params![self.workspace_id,request.after,request.limit.unwrap_or(200).min(200) as i64],read_tag).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
|
||||
self.with_write(|conn| {
|
||||
let input = self.tag_input("mutate_workspace_tag", "", &request)?;
|
||||
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
|
||||
let (before, result, kind) = match &request.action {
|
||||
WorkspaceTagAction::Create { name } => {
|
||||
let (name, normalized) = normalize_name(name)?;
|
||||
self.require_unique_tag_name(conn,&normalized,None)?;
|
||||
let millis = unix_epoch_millis_now().map_err(|error| TicketError::Conflict(format!("failed to read tag ID timestamp: {error}")))?;
|
||||
let id = allocate_record_id(millis, |candidate| {
|
||||
conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,candidate],|_| Ok(())).optional().map(|row| row.is_some()).unwrap_or(true)
|
||||
}).map_err(|error| TicketError::Conflict(error.to_string()))?;
|
||||
let tag = WorkspaceTag { workspace_id: self.workspace_id.clone(), id, name, last_operation_key: request.operation_key.clone(), deleted: false };
|
||||
conn.execute("INSERT INTO workspace_tags (workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES (?1,?2,?3,?4,?5,0)",params![tag.workspace_id,tag.id,tag.name,normalized,tag.last_operation_key]).map_err(sqlite_err)?;
|
||||
(None,tag,"create")
|
||||
}
|
||||
WorkspaceTagAction::Rename { tag_id,expected_last_operation_key,.. } | WorkspaceTagAction::Delete { tag_id,expected_last_operation_key } => {
|
||||
let previous = self.load_tag(conn,tag_id)?;
|
||||
if previous.deleted || &previous.last_operation_key != expected_last_operation_key {
|
||||
return Err(TicketError::Conflict(format!("stale or deleted tag {tag_id}: expected operation {expected_last_operation_key:?}, actual {:?}",previous.last_operation_key)));
|
||||
}
|
||||
let mut tag = previous.clone();
|
||||
tag.last_operation_key = request.operation_key.clone();
|
||||
let kind = if let WorkspaceTagAction::Rename { name,.. } = &request.action {
|
||||
let (name,normalized) = normalize_name(name)?;
|
||||
self.require_unique_tag_name(conn,&normalized,Some(tag_id))?;
|
||||
tag.name = name;
|
||||
conn.execute("UPDATE workspace_tags SET name=?3,normalized_name=?4,last_operation_key=?5 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,tag.name,normalized,request.operation_key]).map_err(sqlite_err)?;
|
||||
"rename"
|
||||
} else {
|
||||
// Capture active snapshots before tombstoning; links and audit are
|
||||
// cleared/written under the same immediate lock as attachments.
|
||||
let ticket_ids = {
|
||||
let mut stmt = conn.prepare("SELECT ticket_id FROM typed_ticket_tags WHERE workspace_id=?1 AND tag_id=?2 ORDER BY ticket_id").map_err(sqlite_err)?;
|
||||
stmt.query_map(params![self.workspace_id,tag_id],|row| row.get::<_,String>(0)).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?
|
||||
};
|
||||
for ticket_id in ticket_ids {
|
||||
let before = self.load_ticket_tags(conn,&ticket_id)?;
|
||||
let after: Vec<_> = before.iter().filter(|tag| &tag.id != tag_id).cloned().collect();
|
||||
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2 AND tag_id=?3",params![self.workspace_id,ticket_id,tag_id]).map_err(sqlite_err)?;
|
||||
self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?;
|
||||
}
|
||||
tag.deleted = true;
|
||||
conn.execute("UPDATE workspace_tags SET deleted=1,last_operation_key=?3 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,request.operation_key]).map_err(sqlite_err)?;
|
||||
"delete"
|
||||
};
|
||||
(Some(previous),tag,kind)
|
||||
}
|
||||
};
|
||||
self.record_tag_operation(conn,&request.operation_key,kind,&result.id,&input,(&before,&result))?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
fn require_unique_tag_name(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
normalized: &str,
|
||||
except: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let duplicate = conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND normalized_name=?2 AND deleted=0 AND (?3 IS NULL OR tag_id<>?3)",params![self.workspace_id,normalized,except],|_| Ok(())).optional().map_err(sqlite_err)?.is_some();
|
||||
if duplicate {
|
||||
return Err(TicketError::Conflict(
|
||||
"active workspace tag name already exists".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: TicketTagSetRequest,
|
||||
) -> Result<Vec<WorkspaceTag>> {
|
||||
self.with_write(|conn| {
|
||||
// Preserve exact selector input, not just its resolved identity.
|
||||
let input = self.tag_input("set_ticket_tags", "", &(&id,&request))?;
|
||||
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
|
||||
let ticket_id = self.resolve_ticket_id(conn,id)?;
|
||||
self.require_active_source_assignment(conn,&ticket_id)?;
|
||||
let before = self.load_ticket_tags(conn,&ticket_id)?;
|
||||
let expected = sorted_ids(&request.expected_tag_ids)?;
|
||||
let desired = sorted_ids(&request.tag_ids)?;
|
||||
if before.iter().map(|tag| tag.id.clone()).collect::<Vec<_>>() != expected {
|
||||
return Err(TicketError::Conflict("stale Ticket tag set".into()));
|
||||
}
|
||||
let after = desired.iter().map(|id| {
|
||||
let tag = self.load_tag(conn,id)?;
|
||||
if tag.deleted { return Err(TicketError::Conflict(format!("workspace tag {id} is deleted"))); }
|
||||
Ok(tag)
|
||||
}).collect::<Result<Vec<_>>>()?;
|
||||
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2",params![self.workspace_id,ticket_id]).map_err(sqlite_err)?;
|
||||
for tag in &after {
|
||||
conn.execute("INSERT INTO typed_ticket_tags (workspace_id,ticket_id,tag_id) VALUES (?1,?2,?3)",params![self.workspace_id,ticket_id,tag.id]).map_err(sqlite_err)?;
|
||||
}
|
||||
if before != after { self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; }
|
||||
self.record_tag_operation(conn,&request.operation_key,"set_ticket_tags",&ticket_id,&input,(&before,&after))?;
|
||||
Ok(after)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() },
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn rename_tag(
|
||||
backend: &SqliteTicketBackend,
|
||||
key: &str,
|
||||
tag: &WorkspaceTag,
|
||||
name: &str,
|
||||
) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Rename {
|
||||
tag_id: tag.id.clone(),
|
||||
expected_last_operation_key: tag.last_operation_key.clone(),
|
||||
name: name.into(),
|
||||
},
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation {
|
||||
WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action: WorkspaceTagAction::Delete {
|
||||
tag_id: tag.id.clone(),
|
||||
expected_last_operation_key: tag.last_operation_key.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn set_request(
|
||||
key: &str,
|
||||
expected: &[WorkspaceTag],
|
||||
desired: &[WorkspaceTag],
|
||||
) -> TicketTagSetRequest {
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: desired.iter().map(|t| t.id.clone()).collect(),
|
||||
operation_key: key.into(),
|
||||
}
|
||||
}
|
||||
fn audit_count(backend: &SqliteTicketBackend) -> i64 {
|
||||
backend
|
||||
.open_connection()
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
|
||||
params![backend.workspace_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}");
|
||||
assert_eq!(tag.name, "Ä BC");
|
||||
for name in ["ä bc", " Ä BC "] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "duplicate".into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() }
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for name in [
|
||||
"",
|
||||
" \u{2003}",
|
||||
"bad\nname",
|
||||
"\tname",
|
||||
"a\0",
|
||||
&"é".repeat(65),
|
||||
] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "invalid".into(),
|
||||
action: WorkspaceTagAction::Create { name: name.into() }
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
create_tag(&backend, "boundary", &"é".repeat(64));
|
||||
create_tag(&backend, "sharp", "ß");
|
||||
create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding.
|
||||
create_tag(&backend, "composed", "é");
|
||||
create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization.
|
||||
assert_eq!(audit_count(&backend), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let a = create_tag(&backend, "a", "A");
|
||||
let b = create_tag(&backend, "b", "B");
|
||||
let renamed = rename_tag(&backend, "rename", &a, "New");
|
||||
assert_eq!(a.id, renamed.id);
|
||||
for action in [
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: a.id.clone(),
|
||||
expected_last_operation_key: "a".into(),
|
||||
name: "Stale".into(),
|
||||
},
|
||||
WorkspaceTagAction::Delete {
|
||||
tag_id: a.id.clone(),
|
||||
expected_last_operation_key: "a".into(),
|
||||
},
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
name: " b ".into(),
|
||||
},
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
name: "\n".into(),
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "rejected".into(),
|
||||
action
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
let active = backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap();
|
||||
assert!(active.contains(&renamed) && active.contains(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = SqliteTicketBackend::open(&db, "ws")
|
||||
.unwrap()
|
||||
.with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-a".into(),
|
||||
)]));
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create { name: " A ".into() },
|
||||
};
|
||||
let original = backend.mutate_workspace_tag(request.clone()).unwrap();
|
||||
let renamed = rename_tag(&backend, "rename", &original, "B");
|
||||
let deletion = delete_request("delete", &renamed);
|
||||
let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap();
|
||||
assert!(tombstone.deleted);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(request.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(deletion.clone()).unwrap(),
|
||||
tombstone
|
||||
);
|
||||
let restarted = SqliteTicketBackend::open_verified(&db, "ws")
|
||||
.unwrap()
|
||||
.with_event_attributes(backend.event_attributes.clone());
|
||||
assert_eq!(
|
||||
restarted.mutate_workspace_tag(request.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
let other_actor = restarted.with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-b".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_actor.mutate_workspace_tag(request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
backend.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create { name: "A".into() }
|
||||
}),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Old name");
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..2 {
|
||||
let id = backend
|
||||
.create(NewTicket::new(format!("ticket {i}")))
|
||||
.unwrap()
|
||||
.id;
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)),
|
||||
)
|
||||
.unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let renamed = rename_tag(&backend, "rename", &tag, "New name");
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &renamed))
|
||||
.unwrap();
|
||||
let replacement = create_tag(&backend, "recreate", "new NAME");
|
||||
assert_ne!(replacement.id, tag.id);
|
||||
for id in ids {
|
||||
let ticket = backend.show(id.into()).unwrap();
|
||||
assert!(ticket.tags.is_empty());
|
||||
let events: Vec<_> = ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.kind == TicketEventKind::TagsChanged)
|
||||
.collect();
|
||||
assert_eq!(events.len(), 2);
|
||||
let first: Vec<WorkspaceTag> =
|
||||
serde_json::from_str(&events[0].attributes["tags_after"]).unwrap();
|
||||
assert_eq!(first, vec![tag.clone()]);
|
||||
let before_delete: Vec<WorkspaceTag> =
|
||||
serde_json::from_str(&events[1].attributes["tags_before"]).unwrap();
|
||||
assert_eq!(before_delete, vec![renamed.clone()]);
|
||||
assert_eq!(events[1].attributes["tags_after"], "[]");
|
||||
}
|
||||
let conn = backend.open_connection().unwrap();
|
||||
let deleted: i64 = conn
|
||||
.query_row(
|
||||
"SELECT deleted FROM workspace_tags WHERE workspace_id='ws' AND tag_id=?1",
|
||||
params![tag.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deleted, 1);
|
||||
assert_eq!(audit_count(&backend), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_tag_set_compares_whole_set_validates_ids_and_replays_historical_result() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let a = create_tag(&backend, "a", "A");
|
||||
let b = create_tag(&backend, "b", "B");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let request = set_request("set", &[], &[b.clone(), a.clone()]);
|
||||
let stored = backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap();
|
||||
assert!(stored.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||
for bad in [
|
||||
set_request("bad", std::slice::from_ref(&a), &[]),
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec![a.id.clone(), a.id.clone()],
|
||||
operation_key: "bad".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec!["missing".into()],
|
||||
operation_key: "bad".into(),
|
||||
},
|
||||
] {
|
||||
assert!(backend.set_ticket_tags(id.clone().into(), bad).is_err());
|
||||
}
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
|
||||
let renamed = rename_tag(&backend, "rename", &a, "Renamed");
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &renamed))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap(),
|
||||
stored
|
||||
);
|
||||
let mut changed = request.clone();
|
||||
changed.tag_ids.reverse();
|
||||
assert!(matches!(
|
||||
backend.set_ticket_tags(id.clone().into(), changed),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
let current = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(current.tags, vec![b]);
|
||||
assert_eq!(
|
||||
current
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.kind == TicketEventKind::TagsChanged)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let other_actor = backend.clone().with_event_attributes(BTreeMap::from([(
|
||||
"source_worker_id".into(),
|
||||
"actor-b".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_actor.set_ticket_tags(id.into(), request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(audit_count(&backend), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_and_operation_keys_are_workspace_scoped() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let one = SqliteTicketBackend::open(&db, "one").unwrap();
|
||||
let two = SqliteTicketBackend::open(&db, "two").unwrap();
|
||||
let a = create_tag(&one, "same-key", "Same name");
|
||||
let b = create_tag(&two, "same-key", "Same name");
|
||||
let ticket = two.create(NewTicket::new("ticket")).unwrap().id;
|
||||
assert!(
|
||||
two.mutate_workspace_tag(delete_request("delete", &a))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
two.set_ticket_tags(ticket.clone().into(), set_request("attach", &[], &[a]))
|
||||
.is_err()
|
||||
);
|
||||
two.set_ticket_tags(
|
||||
ticket.into(),
|
||||
set_request("attach", &[], std::slice::from_ref(&b)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
one.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
two.list_workspace_tags(TagListRequest::default()).unwrap(),
|
||||
vec![b]
|
||||
);
|
||||
assert_eq!(audit_count(&one), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_do_not_change_content_workflow_queue_targets_or_legacy_labels_in_any_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "tag", "open");
|
||||
let closed_tag = create_tag(&backend, "closed-tag", "closed");
|
||||
for (i, state) in [TicketState::Open, TicketState::Closed]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut new = NewTicket::new(format!("ticket {i}"));
|
||||
new.workflow_state = Some(state);
|
||||
new.labels = vec!["legacy label".into()];
|
||||
new.queued_by = Some("user".into());
|
||||
new.queued_at = Some("yesterday".into());
|
||||
let id = backend.create(new).unwrap().id;
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request(&format!("set-{i}"), &[], &[tag.clone(), closed_tag.clone()]),
|
||||
)
|
||||
.unwrap();
|
||||
let after = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(before.meta, after.meta);
|
||||
assert_eq!(before.document, after.document);
|
||||
assert_eq!(before.resolution, after.resolution);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&before),
|
||||
ticket_content_digest(&after)
|
||||
);
|
||||
assert_eq!(before.closure_operation_key, after.closure_operation_key);
|
||||
assert_eq!(before.current_closure, after.current_closure);
|
||||
assert_eq!(before.case_history, after.case_history);
|
||||
assert_eq!(
|
||||
before.legacy_workflow_snapshot,
|
||||
after.legacy_workflow_snapshot
|
||||
);
|
||||
let mut expected_tags = vec![tag.clone(), closed_tag.clone()];
|
||||
expected_tags.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
assert_eq!(after.tags, expected_tags);
|
||||
let summary = backend
|
||||
.list(TicketListQuery::all())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(summary.tags, after.tags);
|
||||
let projection = backend.list_workspace_projection(200).unwrap();
|
||||
assert_eq!(
|
||||
projection
|
||||
.items
|
||||
.iter()
|
||||
.find(|s| s.summary.id == id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.tags,
|
||||
after.tags
|
||||
);
|
||||
let page = backend
|
||||
.list_workspace_projection_page(SqliteTicketListPageQuery {
|
||||
limit: 200,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
page.items
|
||||
.iter()
|
||||
.find(|s| s.summary.id == id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.tags,
|
||||
after.tags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
// Fixture insertion avoids spending this query test on 205 mutation transactions.
|
||||
backend
|
||||
.with_write(|conn| {
|
||||
for i in 0..205 {
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)",
|
||||
params![format!("tag-{i:03}")],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let first = backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(usize::MAX),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(first.len(), 200);
|
||||
assert_eq!(first[0].id, "tag-000");
|
||||
assert_eq!(first[199].id, "tag-199");
|
||||
assert_eq!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap(),
|
||||
first
|
||||
);
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &first[0]))
|
||||
.unwrap();
|
||||
let tail = backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: Some("tag-199".into()),
|
||||
limit: Some(200),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tail.len(), 5);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(0)
|
||||
})
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest {
|
||||
after: None,
|
||||
limit: Some(1)
|
||||
})
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|t| t.id == "tag-001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Tag");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("test hook".into()))
|
||||
}));
|
||||
let attach = set_request("attach", &[], std::slice::from_ref(&tag));
|
||||
assert!(
|
||||
failing
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(backend.show(id.clone().into()).unwrap().tags.is_empty());
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
backend.set_ticket_tags(id.clone().into(), attach).unwrap();
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
let deletion = delete_request("delete", &tag);
|
||||
assert!(failing.mutate_workspace_tag(deletion.clone()).is_err());
|
||||
assert_eq!(backend.show(id.into()).unwrap(), before);
|
||||
assert_eq!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap(),
|
||||
vec![tag]
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 2);
|
||||
backend.mutate_workspace_tag(deletion).unwrap();
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_tag_replay_and_delete_attach_are_serialized() {
|
||||
use std::sync::Barrier;
|
||||
use std::thread;
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = SqliteTicketBackend::open(&db, "ws").unwrap();
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut joins = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
joins.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
create_tag(&backend, "create", "Tag")
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
let tag = joins.remove(0).join().unwrap();
|
||||
assert_eq!(joins.remove(0).join().unwrap(), tag);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let attach = {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
let tag = tag.clone();
|
||||
let id = id.clone();
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag]))
|
||||
})
|
||||
};
|
||||
let delete = {
|
||||
let backend = backend.clone();
|
||||
let barrier = barrier.clone();
|
||||
let tag = tag.clone();
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
backend.mutate_workspace_tag(delete_request("delete", &tag))
|
||||
})
|
||||
};
|
||||
barrier.wait();
|
||||
let attached = attach.join().unwrap();
|
||||
assert!(delete.join().unwrap().unwrap().deleted);
|
||||
assert!(backend.show(id.into()).unwrap().tags.is_empty());
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(TagListRequest::default())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 });
|
||||
let links: i64 = backend
|
||||
.open_connection()
|
||||
.unwrap()
|
||||
.query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(links, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let tag = create_tag(&backend, "create", "Tag");
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let request = set_request("attach", &[], std::slice::from_ref(&tag));
|
||||
let mut reused = request.clone();
|
||||
reused.operation_key = "create".into();
|
||||
assert!(matches!(
|
||||
backend.set_ticket_tags(id.clone().into(), reused),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
let conn = backend.open_connection().unwrap();
|
||||
conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap();
|
||||
conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap();
|
||||
let worker = backend.clone().with_event_attributes(BTreeMap::from([
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_assignment_id".into(), "assignment".into()),
|
||||
]));
|
||||
let stored = worker
|
||||
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM ticket_active_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
worker
|
||||
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
|
||||
.unwrap(),
|
||||
stored
|
||||
);
|
||||
assert!(matches!(
|
||||
worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(
|
||||
worker
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
set_request("new-operation", &stored, &[])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
|
||||
backend
|
||||
.mutate_workspace_tag(delete_request("delete", &tag))
|
||||
.unwrap();
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.into(),
|
||||
set_request("deleted-attach", &[], std::slice::from_ref(&tag))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_domain_dispatch_and_wire_contracts_are_exact() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "Name".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&request).unwrap(),
|
||||
serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}})
|
||||
);
|
||||
let operation = TicketBackendOperation::MutateWorkspaceTag { request };
|
||||
let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
|
||||
let TicketBackendOperationResult::WorkspaceTag(tag) =
|
||||
execute_ticket_backend_operation(&backend, operation).unwrap()
|
||||
else {
|
||||
panic!("tag result")
|
||||
};
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let result = execute_ticket_backend_operation(
|
||||
&backend,
|
||||
TicketBackendOperation::SetTicketTags {
|
||||
id: id.clone().into(),
|
||||
request: set_request("set", &[], std::slice::from_ref(&tag)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()])
|
||||
);
|
||||
let result = execute_ticket_backend_operation(
|
||||
&backend,
|
||||
TicketBackendOperation::ListWorkspaceTags {
|
||||
request: TagListRequest::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag]));
|
||||
let ticket = backend.show(id.into()).unwrap();
|
||||
let mut wire = serde_json::to_value(&ticket).unwrap();
|
||||
wire.as_object_mut().unwrap().remove("tags");
|
||||
assert!(
|
||||
serde_json::from_value::<Ticket>(wire)
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
|
||||
let mut wire = serde_json::to_value(summary).unwrap();
|
||||
wire.as_object_mut().unwrap().remove("tags");
|
||||
assert!(
|
||||
serde_json::from_value::<TicketSummary>(wire)
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
let schema = schemars::schema_for!(WorkspaceTagAction);
|
||||
assert!(
|
||||
serde_json::to_string(&schema)
|
||||
.unwrap()
|
||||
.contains("expected_last_operation_key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_tag_set_records_receipt_without_claiming_a_membership_change() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
|
||||
let before = backend.show(id.clone().into()).unwrap();
|
||||
let request = set_request("noop", &[], &[]);
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request.clone())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), request)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
backend.show(id.clone().into()).unwrap().events,
|
||||
before.events
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
for request in [
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec!["x".into(); 101],
|
||||
tag_ids: vec![],
|
||||
operation_key: "large-observation".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: (0..101).map(|i| format!("tag-{i}")).collect(),
|
||||
operation_key: "large-desired".into(),
|
||||
},
|
||||
TicketTagSetRequest {
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["x".repeat(129)],
|
||||
operation_key: "large-id".into(),
|
||||
},
|
||||
] {
|
||||
assert!(backend.set_ticket_tags(id.clone().into(), request).is_err());
|
||||
assert_eq!(
|
||||
backend.show(id.clone().into()).unwrap().events,
|
||||
before.events
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_receipt_preserves_original_context_but_reassignment_does_not_change_request_identity() {
|
||||
let dir = tempdir().unwrap();
|
||||
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
|
||||
let original_context = BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_assignment_id".into(), "assignment-a".into()),
|
||||
]);
|
||||
let original = backend
|
||||
.clone()
|
||||
.with_event_attributes(original_context.clone());
|
||||
let request = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "ready".into(),
|
||||
},
|
||||
};
|
||||
let tag = original.mutate_workspace_tag(request.clone()).unwrap();
|
||||
let reassigned = backend.clone().with_event_attributes(BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
("source_actor_role".into(), "contributor".into()),
|
||||
]));
|
||||
assert_eq!(reassigned.mutate_workspace_tag(request).unwrap(), tag);
|
||||
let stored: String = backend.with_read(|conn| conn.query_row("SELECT source_context_json FROM workspace_tag_operations WHERE workspace_id='ws' AND operation_key='create'", [], |row| row.get(0)).map_err(sqlite_err)).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<BTreeMap<String, String>>(&stored).unwrap(),
|
||||
original_context
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
}
|
||||
+271
-618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,800 @@
|
||||
//! Public Ticket case authority contracts over isolated SQLite stores.
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn backend(temp: &TempDir) -> SqliteTicketBackend {
|
||||
SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap()
|
||||
}
|
||||
fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(),
|
||||
references: vec![TicketReference {
|
||||
kind: "result".into(),
|
||||
target: "document:example".into(),
|
||||
}],
|
||||
author: Some("human:alice".into()),
|
||||
}
|
||||
}
|
||||
fn close(reason: TicketClosureReason) -> TicketCaseAction {
|
||||
TicketCaseAction::Close { reason }
|
||||
}
|
||||
fn create(backend: &SqliteTicketBackend) -> Ticket {
|
||||
let mut input = NewTicket::new("Independent case");
|
||||
input.assignee = Some("historical-assignee".into());
|
||||
backend.create_with_snapshot(input).unwrap().1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(closed.meta.targets.is_empty());
|
||||
assert_eq!(closed.meta.workflow_state, TicketState::Closed);
|
||||
let first_judgment = closed.current_closure.clone().unwrap();
|
||||
assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned);
|
||||
assert_eq!(first_judgment.author, "human:alice");
|
||||
assert_eq!(
|
||||
first_judgment.references,
|
||||
request(&initial, "close", close(TicketClosureReason::NotPlanned)).references
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Duplicate,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
corrected.current_closure.as_ref().unwrap().reason,
|
||||
TicketClosureReason::Duplicate
|
||||
);
|
||||
assert_eq!(corrected.case_history[0], closed.case_history[0]);
|
||||
assert_eq!(
|
||||
corrected.case_history[1]
|
||||
.previous_closure_operation_key
|
||||
.as_deref(),
|
||||
Some("close")
|
||||
);
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert!(reopened.resolution.is_none());
|
||||
assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct"));
|
||||
assert_eq!(reopened.case_history.len(), 3);
|
||||
assert_eq!(reopened.meta.assignee, initial.meta.assignee);
|
||||
assert_eq!(reopened.meta.resource_key, initial.meta.resource_key);
|
||||
assert_eq!(
|
||||
&reopened.events[..initial.events.len()],
|
||||
initial.events.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
for reason in [
|
||||
TicketClosureReason::Completed,
|
||||
TicketClosureReason::NotPlanned,
|
||||
TicketClosureReason::Duplicate,
|
||||
TicketClosureReason::Unknown,
|
||||
TicketClosureReason::Other("Custom decision".into()),
|
||||
] {
|
||||
let initial = create(&store);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(reason.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(closed.current_closure.unwrap().reason, reason);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_content_digest = "stale".into();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleContent { .. })
|
||||
));
|
||||
let mut stale = base.clone();
|
||||
stale.expected_state = TicketState::Closed;
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleWorkflowState { .. })
|
||||
));
|
||||
let mut stale = base;
|
||||
stale.expected_closure_operation_key = Some("unobserved".into());
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_keys_protect_closed_correction_and_open_closed_open_aba() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let stale_open = request(
|
||||
&initial,
|
||||
"late-close",
|
||||
close(TicketClosureReason::Completed),
|
||||
);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
let stale_correction = request(
|
||||
&closed,
|
||||
"late-correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
);
|
||||
let corrected = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::NotPlanned,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_correction),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ticket_content_digest(&reopened),
|
||||
ticket_content_digest(&initial)
|
||||
);
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, stale_open),
|
||||
Err(TicketError::StaleClosureOperationKey { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
store.show(initial.meta.id.clone().into()).unwrap(),
|
||||
reopened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("Changed requirements".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edited.case_history, reopened.case_history);
|
||||
let restored =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap();
|
||||
assert_eq!(
|
||||
restored.update_case(&initial.meta.id, operation).unwrap(),
|
||||
edited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let closed = store
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
let mut changed = Vec::new();
|
||||
let mut x = operation.clone();
|
||||
x.expected_content_digest.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_state = TicketState::Closed;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.expected_closure_operation_key = Some("close".into());
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = close(TicketClosureReason::NotPlanned);
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.action = TicketCaseAction::Reopen;
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.explanation.push('x');
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.references.clear();
|
||||
changed.push(x);
|
||||
let mut x = operation.clone();
|
||||
x.author = None;
|
||||
changed.push(x);
|
||||
for input in changed {
|
||||
assert!(matches!(
|
||||
store.update_case(&initial.meta.id, input),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
}
|
||||
let other_source = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"source_runtime_id".into(),
|
||||
"other-runtime".into(),
|
||||
)]));
|
||||
assert!(matches!(
|
||||
other_source.update_case(&initial.meta.id, operation),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for action in [
|
||||
TicketCaseAction::Reopen,
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(&initial.meta.id, request(&initial, "invalid", action))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&closed,
|
||||
"double-close",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
for reason in [
|
||||
"".into(),
|
||||
" \n".into(),
|
||||
"bad\0reason".into(),
|
||||
"あ".repeat(342),
|
||||
] {
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "bad", close(TicketClosureReason::Other(reason)))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut valid = request(
|
||||
&initial,
|
||||
"close",
|
||||
close(TicketClosureReason::Other("x".repeat(1024))),
|
||||
);
|
||||
valid.explanation = format!(
|
||||
"## Decision\n{}\n<!-- typed SQLite text -->",
|
||||
"long".repeat(8000)
|
||||
);
|
||||
let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap();
|
||||
assert_eq!(
|
||||
closed.current_closure.as_ref().unwrap().explanation,
|
||||
valid.explanation
|
||||
);
|
||||
assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let base = request(&initial, "key", close(TicketClosureReason::Unknown));
|
||||
let mut cases = Vec::new();
|
||||
let mut x = base.clone();
|
||||
x.explanation = " ".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.explanation = "bad\0text".into();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.author = Some(" ".into());
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].target.clear();
|
||||
cases.push(x);
|
||||
let mut x = base.clone();
|
||||
x.references[0].kind.clear();
|
||||
cases.push(x);
|
||||
let mut x = base;
|
||||
x.operation_key.clear();
|
||||
cases.push(x);
|
||||
for input in cases {
|
||||
assert!(store.update_case(&initial.meta.id, input).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("hook rejected".into()))
|
||||
}));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
assert!(
|
||||
rejected
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let closed = store.update_case(&initial.meta.id, operation).unwrap();
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
for sql in [
|
||||
"UPDATE typed_ticket_case_history SET judgment_json = '{}'",
|
||||
"DELETE FROM typed_ticket_case_history",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err());
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
let mut joins = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = operation.clone();
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
for join in joins {
|
||||
join.join().unwrap().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let initial = create(&store);
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let mut joins = Vec::new();
|
||||
for key in ["winner-a", "winner-b"] {
|
||||
let store = store.clone();
|
||||
let barrier = barrier.clone();
|
||||
let operation = request(&initial, key, close(TicketClosureReason::Unknown));
|
||||
let id = initial.meta.id.clone();
|
||||
joins.push(std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.update_case(&id, operation)
|
||||
}));
|
||||
}
|
||||
let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect();
|
||||
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. })))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.show(initial.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.case_history
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let ready = store
|
||||
.mark_ready(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready".into(),
|
||||
reason: None,
|
||||
intake_summary: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(ready.meta.readiness.as_deref(), Some("ready"));
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.unwrap();
|
||||
let queued = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
assert_eq!(queued.meta.workflow_state, TicketState::Open);
|
||||
assert_eq!(queued.meta.queued_by.as_deref(), Some("router"));
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&queued, "close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.queue_ready(initial.meta.id.clone().into(), "router")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let other =
|
||||
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace")
|
||||
.unwrap();
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
assert!(matches!(
|
||||
other.update_case(&initial.meta.id, operation.clone()),
|
||||
Err(TicketError::NotFound(_))
|
||||
));
|
||||
let output = execute_ticket_backend_operation(
|
||||
&store,
|
||||
TicketBackendOperation::UpdateCase {
|
||||
ticket: initial.meta.id.clone(),
|
||||
request: operation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let historical = store.clone().with_event_attributes(BTreeMap::from([
|
||||
("operation_key".into(), "old-completion".into()),
|
||||
("merge_request_id".into(), "MR-existing".into()),
|
||||
]));
|
||||
let mut event = NewTicketEvent::new(
|
||||
TicketEventKind::Other("ticket_completion".into()),
|
||||
"Historic completed result",
|
||||
);
|
||||
event.references = vec![TicketReference {
|
||||
kind: "merge_request".into(),
|
||||
target: "MR-existing".into(),
|
||||
}];
|
||||
historical
|
||||
.add_event(initial.meta.id.clone().into(), event)
|
||||
.unwrap();
|
||||
let before = store.show(initial.meta.id.clone().into()).unwrap();
|
||||
let after = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&before, "new-close", close(TicketClosureReason::NotPlanned)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&after.events[..before.events.len()],
|
||||
before.events.as_slice()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(
|
||||
&before,
|
||||
"old-completion",
|
||||
close(TicketClosureReason::Completed)
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() {
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"in_progress",
|
||||
] {
|
||||
assert!(serde_json::from_value::<TicketState>(serde_json::json!(state)).is_err());
|
||||
}
|
||||
let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}});
|
||||
let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(action).unwrap(), encoded);
|
||||
assert!(
|
||||
serde_json::from_value::<TicketCaseAction>(
|
||||
serde_json::json!({"action": "reopen", "reason": "completed"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn assignment_source() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime-a".into()),
|
||||
("source_worker_id".into(), "worker-a".into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
("source_assignment_id".into(), "assignment-a".into()),
|
||||
])
|
||||
}
|
||||
fn assignment_fixture(temp: &TempDir, id: &str) -> Connection {
|
||||
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
|
||||
conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT);
|
||||
CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap();
|
||||
conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let conn = assignment_fixture(&temp, &initial.meta.id);
|
||||
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
"source_actor_role",
|
||||
] {
|
||||
let mut wrong = assignment_source();
|
||||
wrong.insert(key.into(), "mismatch".into());
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(wrong)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for key in [
|
||||
"source_runtime_id",
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
] {
|
||||
let mut missing = assignment_source();
|
||||
missing.remove(key);
|
||||
assert!(
|
||||
store
|
||||
.clone()
|
||||
.with_event_attributes(missing)
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
let source = store.clone().with_event_attributes(assignment_source());
|
||||
let closed = source
|
||||
.update_case(&initial.meta.id, operation.clone())
|
||||
.unwrap();
|
||||
// The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this.
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"active"
|
||||
);
|
||||
conn.execute("UPDATE test_worker_assignments SET status = 'released'", [])
|
||||
.unwrap();
|
||||
let reopened = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&closed, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
source.update_case(&initial.meta.id, operation).unwrap(),
|
||||
reopened
|
||||
);
|
||||
assert!(
|
||||
source
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&reopened, "new-close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"released"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let digest = ticket_content_digest(&initial);
|
||||
let closed = store
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&closed), digest);
|
||||
let edited = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(MarkdownText::new("New criteria")),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&edited), digest);
|
||||
let restored = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
body: Some(initial.document.body.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_content_digest(&restored), digest);
|
||||
let targeted = store
|
||||
.edit_item(
|
||||
initial.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
targets: Some(TicketTargetsEdit::Set {
|
||||
targets: vec![TicketTarget {
|
||||
repository_key: "main".into(),
|
||||
ref_selector: None,
|
||||
access: TicketTargetAccess::ReadOnly,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(ticket_content_digest(&targeted), digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_case_request_requires_an_explicit_nullable_closure_observation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let original = request(&initial, "close", close(TicketClosureReason::Unknown));
|
||||
let mut json = serde_json::to_value(&original).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TicketCaseUpdate>(json.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
json.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("expected_closure_operation_key");
|
||||
assert!(serde_json::from_value::<TicketCaseUpdate>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let store = backend(&temp);
|
||||
let initial = create(&store);
|
||||
let context = store.clone().with_event_attributes(BTreeMap::from([(
|
||||
"operation_key".into(),
|
||||
"wrong-key".into(),
|
||||
)]));
|
||||
assert!(
|
||||
context
|
||||
.update_case(
|
||||
&initial.meta.id,
|
||||
request(&initial, "close", close(TicketClosureReason::Unknown))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract.
|
||||
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_attributes (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_event_references (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
event_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
author TEXT,
|
||||
at TEXT,
|
||||
status TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
reason TEXT,
|
||||
state_field TEXT,
|
||||
heading TEXT,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, event_index),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_labels (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_orchestration_plans (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
related_ticket TEXT,
|
||||
note TEXT,
|
||||
accepted_summary TEXT,
|
||||
accepted_branch TEXT,
|
||||
accepted_worktree TEXT,
|
||||
accepted_role_plan TEXT,
|
||||
author TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, record_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_raw_frontmatter (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_relations (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, kind, target),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_risk_flags (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_tickets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
assignee TEXT,
|
||||
readiness TEXT,
|
||||
workflow_state TEXT NOT NULL,
|
||||
workflow_state_explicit INTEGER NOT NULL,
|
||||
queued_by TEXT,
|
||||
queued_at TEXT,
|
||||
resolution TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id)
|
||||
);
|
||||
CREATE TABLE typed_ticket_targets (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
repository_key TEXT NOT NULL,
|
||||
ref_selector TEXT,
|
||||
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
|
||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
||||
UNIQUE (workspace_id, ticket_id, repository_key),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE "workspace_resource_key_counters" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
||||
PRIMARY KEY (workspace_id, resource_kind)
|
||||
);
|
||||
CREATE TABLE "workspace_resource_keys" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
||||
resource_id TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
||||
resource_key TEXT NOT NULL,
|
||||
allocated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
||||
UNIQUE (workspace_id, resource_kind, sequence),
|
||||
UNIQUE (workspace_id, resource_key)
|
||||
);
|
||||
CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE INDEX typed_ticket_relations_workspace_source_kind
|
||||
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
|
||||
CREATE INDEX typed_ticket_relations_workspace_target_kind
|
||||
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_created
|
||||
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_state_updated
|
||||
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_title
|
||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
||||
CREATE INDEX typed_tickets_workspace_updated
|
||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- Frozen standalone component8 tag extension; combine with schema7.sql.
|
||||
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
|
||||
CREATE TABLE workspace_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
last_operation_key TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
|
||||
PRIMARY KEY (workspace_id, tag_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_tags_active_name
|
||||
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
|
||||
CREATE TABLE typed_ticket_tags (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, tag_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX typed_ticket_tags_workspace_tag
|
||||
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
|
||||
-- Immutable operation receipt and Workspace audit: exact input includes actor
|
||||
-- attributes; before/result preserve stable identities and display-name snapshots.
|
||||
-- No foreign keys to mutable rows: history survives deletion of current links.
|
||||
CREATE TABLE workspace_tag_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
input_json TEXT NOT NULL,
|
||||
source_context_json TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_key)
|
||||
);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- Frozen standalone component9 case extension; combine with schema7.sql.
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
@@ -0,0 +1,818 @@
|
||||
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
|
||||
//! compose into schema10, preserving original evidence and routing handoff.
|
||||
use rusqlite::{Connection, params};
|
||||
use tempfile::TempDir;
|
||||
use ticket::*;
|
||||
|
||||
fn schema7(connection: &Connection) {
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema7.sql"))
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
|
||||
{
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("tickets.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
|
||||
.unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
|
||||
// Actual pre-content receipt spelling: preserve it as historical input,
|
||||
// never promote an old counter fingerprint into a current content CAS.
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
|
||||
connection.execute(
|
||||
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
|
||||
[state],
|
||||
).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
|
||||
params![state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Snapshot evidence before migration, including untyped `done` and existing MR references.
|
||||
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
let old_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let preserved_attributes: Vec<(String, String, String)> = connection
|
||||
.prepare(
|
||||
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(old_attributes, preserved_attributes);
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
let marker: i64 = connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(marker, 10);
|
||||
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
|
||||
assert_eq!(after_events, historical_events);
|
||||
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
let ticket = store.show(state.into()).unwrap();
|
||||
let expected_state = if matches!(state, "done" | "closed") {
|
||||
TicketState::Closed
|
||||
} else {
|
||||
TicketState::Open
|
||||
};
|
||||
assert_eq!(ticket.meta.workflow_state, expected_state);
|
||||
let old_key_request = TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "legacy-receipt".into(),
|
||||
action: if expected_state == TicketState::Closed {
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
} else {
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
}
|
||||
},
|
||||
explanation: "Cannot reinterpret a frozen operation receipt".into(),
|
||||
references: vec![],
|
||||
author: Some("human".into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.update_case(state, old_key_request),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(store.show(state.into()).unwrap(), ticket);
|
||||
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
|
||||
assert_eq!(legacy.workflow_state, state);
|
||||
assert!(legacy.workflow_state_explicit);
|
||||
assert_eq!(legacy.status, "open");
|
||||
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
|
||||
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
|
||||
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
|
||||
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
|
||||
assert_eq!(ticket.meta.targets[0].repository_key, "main");
|
||||
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
|
||||
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
|
||||
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
|
||||
assert_eq!(
|
||||
ticket.events[0].attributes["request_fingerprint"],
|
||||
"legacy-fingerprint"
|
||||
);
|
||||
if expected_state == TicketState::Closed {
|
||||
let judgment = ticket.current_closure.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
judgment.reason,
|
||||
TicketClosureReason::Unknown,
|
||||
"old done must never mean successful completion"
|
||||
);
|
||||
assert_eq!(judgment.explanation, "Original decision text");
|
||||
let reopened = store
|
||||
.update_case(
|
||||
state,
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&ticket),
|
||||
expected_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: "reopen-migrated".into(),
|
||||
action: TicketCaseAction::Reopen,
|
||||
explanation: "Reconsider the case".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("human".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reopened.current_closure.is_none());
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(
|
||||
reopened.legacy_workflow_snapshot,
|
||||
ticket.legacy_workflow_snapshot
|
||||
);
|
||||
} else {
|
||||
assert!(ticket.current_closure.is_none());
|
||||
}
|
||||
}
|
||||
let released: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(released, 6);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
7
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
|
||||
.get::<_, String>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
"future-state"
|
||||
);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
|
||||
.unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
// Read actual SQLite values, not merely counts: receipt JSON, original context,
|
||||
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
|
||||
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
let mut stmt = connection
|
||||
.prepare(&format!("SELECT * FROM {table} ORDER BY rowid"))
|
||||
.unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) {
|
||||
connection
|
||||
.execute(
|
||||
"ATTACH DATABASE ?1 AS fixture_source",
|
||||
[source.to_str().unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
for table in tables {
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"INSERT INTO {table} SELECT * FROM fixture_source.{table}"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
connection
|
||||
.execute_batch("DETACH DATABASE fixture_source")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag {
|
||||
backend
|
||||
.mutate_workspace_tag(WorkspaceTagMutation {
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
|
||||
TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(ticket),
|
||||
expected_state: ticket.meta.workflow_state,
|
||||
expected_closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "Retain this judgment".into(),
|
||||
references: vec![TicketReference {
|
||||
kind: "result".into(),
|
||||
target: "retained-result".into(),
|
||||
}],
|
||||
author: Some("authenticated-user".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source_path = temp.path().join("tag-operations.db");
|
||||
let source = SqliteTicketBackend::open(&source_path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
("source_actor_role".into(), "human".into()),
|
||||
]));
|
||||
let id = source
|
||||
.create(NewTicket::new("historical tagged ticket"))
|
||||
.unwrap()
|
||||
.id;
|
||||
// Generate real durable T-740 receipts/audit/event snapshots through its
|
||||
// unchanged classification boundary, then seed only those rows into frozen8.
|
||||
let create = WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: WorkspaceTagAction::Create {
|
||||
name: "open".into(),
|
||||
},
|
||||
};
|
||||
let original = source.mutate_workspace_tag(create.clone()).unwrap();
|
||||
let attach = TicketTagSetRequest {
|
||||
operation_key: "attach".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec![original.id.clone()],
|
||||
};
|
||||
let attach_result = source
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.unwrap();
|
||||
let renamed = mutate(
|
||||
&source,
|
||||
"rename",
|
||||
WorkspaceTagAction::Rename {
|
||||
tag_id: original.id.clone(),
|
||||
expected_last_operation_key: "create".into(),
|
||||
name: "closed".into(),
|
||||
},
|
||||
);
|
||||
let deleted = mutate(
|
||||
&source,
|
||||
"delete",
|
||||
WorkspaceTagAction::Delete {
|
||||
tag_id: renamed.id.clone(),
|
||||
expected_last_operation_key: "rename".into(),
|
||||
},
|
||||
);
|
||||
let recreated = mutate(
|
||||
&source,
|
||||
"recreate",
|
||||
WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
);
|
||||
assert_ne!(recreated.id, deleted.id);
|
||||
source
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
TicketTagSetRequest {
|
||||
operation_key: "attach-recreated".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec![recreated.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = temp.path().join("retained8.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema8_tags.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version=8", [])
|
||||
.unwrap();
|
||||
let preserved = [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_event_references",
|
||||
];
|
||||
copy_tables(&connection, &source_path, &["typed_tickets"]);
|
||||
copy_tables(&connection, &source_path, &preserved);
|
||||
// Six-state retained data, not a current10 DB with its number changed.
|
||||
connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap();
|
||||
let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect();
|
||||
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert!(verify_sqlite_ticket_schema(&connection).is_err());
|
||||
assert!(verify_pre_classification_ticket_schema(&connection).is_err());
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
rows(&connection, "ticket_schema_migrations")[0][0],
|
||||
rusqlite::types::Value::Integer(10)
|
||||
);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(&rows(&connection, table), expected, "changed {table}");
|
||||
}
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
("source_actor_role".into(), "human".into()),
|
||||
]));
|
||||
let ticket = backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(ticket.tags, vec![recreated]);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&ticket),
|
||||
ticket_content_digest(&source.show(id.clone().into()).unwrap())
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.update_case(
|
||||
&id,
|
||||
case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen)
|
||||
),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
ticket.current_closure.as_ref().unwrap().reason,
|
||||
TicketClosureReason::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
ticket.current_closure.as_ref().unwrap().explanation,
|
||||
"Historical explanation"
|
||||
);
|
||||
assert_eq!(
|
||||
ticket
|
||||
.legacy_workflow_snapshot
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.workflow_state,
|
||||
"done"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.mutate_workspace_tag(create.clone()).unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
backend
|
||||
.set_ticket_tags(id.clone().into(), attach.clone())
|
||||
.unwrap(),
|
||||
attach_result
|
||||
);
|
||||
let other = backend
|
||||
.clone()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_account_id".into(), "other-account".into()),
|
||||
("source_user_id".into(), "user".into()),
|
||||
]));
|
||||
assert!(matches!(
|
||||
other.mutate_workspace_tag(create),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
other.set_ticket_tags(id.clone().into(), attach),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.clone().into(),
|
||||
TicketTagSetRequest {
|
||||
operation_key: "attach-deleted".into(),
|
||||
expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(),
|
||||
tag_ids: vec![deleted.id.clone()],
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)",
|
||||
params![id, deleted.id]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let judgments = rows(&connection, "typed_ticket_case_history");
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(
|
||||
&rows(&connection, table),
|
||||
expected,
|
||||
"replay changed {table}"
|
||||
);
|
||||
}
|
||||
let reopened = backend
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(&ticket, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.tags, ticket.tags);
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(
|
||||
ticket_content_digest(&reopened),
|
||||
ticket_content_digest(&ticket)
|
||||
);
|
||||
assert!(reopened.current_closure.is_none());
|
||||
let fk_failures: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fk_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let source_path = temp.path().join("case-operations.db");
|
||||
let source = SqliteTicketBackend::open(&source_path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
]));
|
||||
let id = source.create(NewTicket::new("existing case")).unwrap().id;
|
||||
let before = source.show(id.clone().into()).unwrap();
|
||||
let close = case_request(
|
||||
&before,
|
||||
"close",
|
||||
TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Duplicate,
|
||||
},
|
||||
);
|
||||
let closed = source.update_case(&id, close.clone()).unwrap();
|
||||
let corrected = source
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(
|
||||
&closed,
|
||||
"correct",
|
||||
TicketCaseAction::CorrectClosure {
|
||||
reason: TicketClosureReason::NotPlanned,
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = source
|
||||
.update_case(
|
||||
&id,
|
||||
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
|
||||
)
|
||||
.unwrap();
|
||||
let path = temp.path().join("retained9.db");
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
schema7(&connection);
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
|
||||
.unwrap();
|
||||
connection
|
||||
.execute("UPDATE ticket_schema_migrations SET version=9", [])
|
||||
.unwrap();
|
||||
let tables = [
|
||||
"typed_tickets",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_event_references",
|
||||
"workspace_resource_keys",
|
||||
"workspace_resource_key_counters",
|
||||
];
|
||||
copy_tables(&connection, &source_path, &tables);
|
||||
// A migrated closed case already judged Unknown by standalone9 must remain
|
||||
// exactly that judgment, with its original migration actor/key and handoff.
|
||||
connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap();
|
||||
let snapshot = TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: "done".into(),
|
||||
workflow_state_explicit: true,
|
||||
status: "done".into(),
|
||||
queued_by: Some("original-router".into()),
|
||||
queued_at: Some("original-time".into()),
|
||||
resolution: Some("Original legacy close".into()),
|
||||
};
|
||||
let judgment = TicketCaseHistoryEntry {
|
||||
operation_key: "migration-v9:old9".into(),
|
||||
previous_closure_operation_key: None,
|
||||
action: TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Original legacy close".into(),
|
||||
references: vec![],
|
||||
author: "ticket-schema-migration-v9".into(),
|
||||
at: "original-migration-time".into(),
|
||||
};
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)",
|
||||
[serde_json::to_string(&snapshot).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap();
|
||||
let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
for (table, expected) in tables.iter().zip(&original) {
|
||||
assert_eq!(
|
||||
&rows(&connection, table),
|
||||
expected,
|
||||
"changed independent9 {table}"
|
||||
);
|
||||
}
|
||||
assert!(rows(&connection, "workspace_tags").is_empty());
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
|
||||
.unwrap()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
]));
|
||||
let old9 = backend.show("old9".into()).unwrap();
|
||||
assert_eq!(old9.case_history, vec![judgment]);
|
||||
assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot));
|
||||
assert_eq!(
|
||||
old9.current_closure.unwrap().reason,
|
||||
TicketClosureReason::Unknown
|
||||
);
|
||||
assert_eq!(backend.show(id.clone().into()).unwrap(), reopened);
|
||||
assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened);
|
||||
let other = backend
|
||||
.clone()
|
||||
.with_event_attributes(std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "other-worker".into()),
|
||||
]));
|
||||
assert!(matches!(
|
||||
other.update_case(&id, close),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
|
||||
connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert!(!connection.is_autocommit());
|
||||
connection.execute_batch("ROLLBACK").unwrap();
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert!(rows(&connection, "host_work").is_empty());
|
||||
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() {
|
||||
for drift in [
|
||||
"UPDATE ticket_schema_migrations SET version=9",
|
||||
"UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;",
|
||||
] {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
if drift.contains("DROP TRIGGER") {
|
||||
connection
|
||||
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
|
||||
.unwrap();
|
||||
}
|
||||
connection.execute_batch(drift).unwrap();
|
||||
let marker = rows(&connection, "ticket_schema_migrations");
|
||||
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
|
||||
assert_eq!(rows(&connection, "ticket_schema_migrations"), marker);
|
||||
let tags: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
schema7(&connection);
|
||||
connection.execute_batch("CREATE TABLE host_work (value TEXT);
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1);
|
||||
CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
BEGIN SELECT RAISE(ABORT,'host failure'); END;
|
||||
BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
|
||||
assert!(!connection.is_autocommit());
|
||||
verify_pre_classification_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "host_work").len(), 1);
|
||||
let tags: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags, 0, "DDL before failed state conversion must roll back");
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER host_abort_case_update")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(rows(&connection, "host_work").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
connection
|
||||
.pragma_update(None, "foreign_keys", "ON")
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap();
|
||||
// Ticket accepts stricter host-owned Workspace references and must preserve
|
||||
// them, not rebuild parents against its weaker standalone authority schema.
|
||||
let schema = include_str!("fixtures/schema7.sql");
|
||||
let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap();
|
||||
let (ticket, after) = ticket
|
||||
.split_once("CREATE TABLE typed_ticket_targets (")
|
||||
.unwrap();
|
||||
let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)",
|
||||
"PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}"
|
||||
))
|
||||
.unwrap();
|
||||
let tags = include_str!("fixtures/schema8_tags.sql")
|
||||
.replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE")
|
||||
.replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
|
||||
connection.execute_batch(&tags).unwrap();
|
||||
connection.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
|
||||
INSERT INTO ticket_schema_migrations VALUES(8,'ticket schema baseline','retained');
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1);
|
||||
INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0);
|
||||
INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap();
|
||||
let signature = |table| {
|
||||
let mut stmt = connection
|
||||
.prepare(&format!(
|
||||
"SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"
|
||||
))
|
||||
.unwrap();
|
||||
let count = stmt.column_count();
|
||||
stmt.query_map([], |row| {
|
||||
(0..count)
|
||||
.map(|i| row.get::<_, rusqlite::types::Value>(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let tables = [
|
||||
"typed_tickets",
|
||||
"workspace_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_tags",
|
||||
];
|
||||
let before: Vec<_> = tables.iter().map(|t| signature(t)).collect();
|
||||
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
for (table, expected) in tables.iter().zip(before) {
|
||||
assert_eq!(signature(table), expected, "lost host FK on {table}");
|
||||
}
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let failures: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failures, 0);
|
||||
}
|
||||
+145
-16
@@ -435,16 +435,23 @@ mod client {
|
||||
use super::*;
|
||||
use crate::{Workdir, WorkdirSession};
|
||||
|
||||
/// Provides a fresh bearer token for each Runtime request. Backend
|
||||
/// implementations can mint short-lived capability tokens without making a
|
||||
/// Worker-bound session expire with the token used to open it.
|
||||
/// An owned, Send authorization future that can offload synchronous authority
|
||||
/// work without borrowing request data or the authorization provider.
|
||||
pub type WorkdirHttpAuthorizationFuture = std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<String, WorkdirError>> + Send + 'static>,
|
||||
>;
|
||||
|
||||
/// Provides a fresh bearer token asynchronously for each Runtime request.
|
||||
/// Backend implementations can mint short-lived capability tokens without
|
||||
/// making a Worker-bound session expire with the token used to open it.
|
||||
/// Blocking DB/signing work must be offloaded by the implementation.
|
||||
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
) -> Result<String, WorkdirError>;
|
||||
method: String,
|
||||
path_and_query: String,
|
||||
body: Vec<u8>,
|
||||
) -> WorkdirHttpAuthorizationFuture;
|
||||
}
|
||||
|
||||
struct FixedBearerToken(Arc<str>);
|
||||
@@ -458,11 +465,12 @@ mod client {
|
||||
impl WorkdirHttpAuthorization for FixedBearerToken {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
_method: &str,
|
||||
_path_and_query: &str,
|
||||
_body: &[u8],
|
||||
) -> Result<String, WorkdirError> {
|
||||
Ok(self.0.to_string())
|
||||
_method: String,
|
||||
_path_and_query: String,
|
||||
_body: Vec<u8>,
|
||||
) -> WorkdirHttpAuthorizationFuture {
|
||||
let token = self.0.clone();
|
||||
Box::pin(async move { Ok(token.to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +520,9 @@ mod client {
|
||||
)?;
|
||||
let body = serde_json::to_vec(&request)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
let token = authorization.bearer_token("POST", url.path(), &body)?;
|
||||
let token = authorization
|
||||
.bearer_token("POST".to_string(), url.path().to_string(), body.clone())
|
||||
.await?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(token)
|
||||
@@ -563,7 +573,10 @@ mod client {
|
||||
let operation = WorkdirSessionOperationRequest { operation };
|
||||
let body = serde_json::to_vec(&operation)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
|
||||
let token = self
|
||||
.authorization
|
||||
.bearer_token("POST".to_string(), url.path().to_string(), body.clone())
|
||||
.await?;
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
@@ -867,7 +880,10 @@ mod client {
|
||||
&self.base_url,
|
||||
&["v1", "workdir-sessions", self.session_id.as_str()],
|
||||
)?;
|
||||
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
|
||||
let token = self
|
||||
.authorization
|
||||
.bearer_token("DELETE".to_string(), url.path().to_string(), Vec::new())
|
||||
.await?;
|
||||
let response = self
|
||||
.client
|
||||
.delete(url)
|
||||
@@ -921,11 +937,124 @@ mod client {
|
||||
WorkdirError::Transport("Runtime Workdir HTTP request failed".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod authorization_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RejectAuthorization {
|
||||
requests: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
}
|
||||
|
||||
impl WorkdirHttpAuthorization for RejectAuthorization {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
method: String,
|
||||
path_and_query: String,
|
||||
body: Vec<u8>,
|
||||
) -> WorkdirHttpAuthorizationFuture {
|
||||
let requests = self.requests.clone();
|
||||
Box::pin(async move {
|
||||
tokio::task::yield_now().await;
|
||||
let mut requests = requests.lock().unwrap();
|
||||
requests.push((method, path_and_query, body));
|
||||
Err(WorkdirError::Unavailable(format!(
|
||||
"authorization rejected request {}",
|
||||
requests.len()
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_operate_close_await_fresh_authorization_and_do_not_send_when_rejected() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let base_url: Url = format!("http://{}", listener.local_addr().unwrap())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
let authorization = Arc::new(RejectAuthorization::default());
|
||||
let workdir = Workdir::new("w");
|
||||
let open_request = OpenWorkdirSessionRequest {
|
||||
owner_worker_id: Some("worker".to_string()),
|
||||
};
|
||||
let error = RemoteWorkdirSession::open_with_authorization(
|
||||
client.clone(),
|
||||
base_url.clone(),
|
||||
authorization.clone(),
|
||||
workdir.id().clone(),
|
||||
open_request.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unavailable(message) if message == "authorization rejected request 1"
|
||||
));
|
||||
|
||||
// Seed an already-open session to exercise the public operation and
|
||||
// close boundaries independently of the rejected open request.
|
||||
let remote = RemoteWorkdirSession {
|
||||
client,
|
||||
base_url,
|
||||
authorization: authorization.clone(),
|
||||
workdir,
|
||||
session_id: WorkdirSessionId::new("s").unwrap(),
|
||||
capabilities: WorkdirSessionCapabilities::EMPTY,
|
||||
closed: AtomicBool::new(false),
|
||||
};
|
||||
let stat = StatRequest {
|
||||
path: crate::WorkdirPath::new("file").unwrap(),
|
||||
};
|
||||
let error = remote.stat(stat.clone()).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unavailable(message) if message == "authorization rejected request 2"
|
||||
));
|
||||
let error = remote.close().await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unavailable(message) if message == "authorization rejected request 3"
|
||||
));
|
||||
assert_eq!(
|
||||
*authorization.requests.lock().unwrap(),
|
||||
vec![
|
||||
(
|
||||
"POST".to_string(),
|
||||
"/v1/working-directories/w/sessions".to_string(),
|
||||
serde_json::to_vec(&open_request).unwrap(),
|
||||
),
|
||||
(
|
||||
"POST".to_string(),
|
||||
"/v1/workdir-sessions/s/operations".to_string(),
|
||||
serde_json::to_vec(&WorkdirSessionOperationRequest {
|
||||
operation: WorkdirSessionOperation::Stat(stat),
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
"DELETE".to_string(),
|
||||
"/v1/workdir-sessions/s".to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub use self::RemoteWorkdirSession as ClientSession;
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-client")]
|
||||
pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization};
|
||||
pub use client::{
|
||||
ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Types shared by Server and Runtime expose no provider/session handles, host paths, Runtime URLs,
|
||||
//! or credentials. A Workdir's inventory presence is separate from content access through a Worker's
|
||||
//! live attachment. Status and past materialization evidence alone do not grant current scope
|
||||
//! or cleanup authority.
|
||||
//! or cleanup authority. A retained partial-removal Workdir is CleanupPending with cleanliness
|
||||
//! unknown; saved removal metadata is not a current cleanliness assertion. Provider-confirmed
|
||||
//! physical deletion or authoritative not-found, not a missing response, permits registry removal.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! command surface for a trusted backend/proxy. Browsers must not connect to the
|
||||
//! Runtime process directly; a backend is expected to own any browser-facing
|
||||
//! credentials, registration, and policy.
|
||||
//!
|
||||
//! Workdir list/detail/removal offload synchronous provider work to blocking workers.
|
||||
//! Removal reserves only its target under the Runtime state mutex, releasing it before
|
||||
//! filesystem/Git inspection and unlink; conflicting target use is rejected promptly.
|
||||
|
||||
mod runtime_management_api;
|
||||
|
||||
|
||||
@@ -2545,16 +2545,24 @@ where
|
||||
}
|
||||
}
|
||||
method => {
|
||||
let result = self.send_method(
|
||||
WorkerExecutionOperation::ProtocolMethod,
|
||||
execution.handle.clone(),
|
||||
method,
|
||||
);
|
||||
if result.is_accepted() {
|
||||
// The connected protocol owns this bounded admission wait. The old
|
||||
// generic adapter waiter could time out while leaving a detached send
|
||||
// task alive; it might deliver later after the connection had ended.
|
||||
// Join cancellation before releasing endpoint exclusion. A send which
|
||||
// raced cancellation remains delivery-unknown, never automatically retried.
|
||||
let handle = execution.handle.clone();
|
||||
self.run_cancellable_on_adapter_runtime(USER_INPUT_TASK_TIMEOUT, async move {
|
||||
handle.send(method).await.map_err(|error| {
|
||||
format!("connected Worker method delivery failed or is unknown: {error}")
|
||||
})?;
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Err(result)
|
||||
}
|
||||
})
|
||||
.map_err(|message| {
|
||||
WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::ProtocolMethod,
|
||||
message,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7905,6 +7913,48 @@ mod tests {
|
||||
assert!(backend.worker_snapshot(&worker_ref).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_protocol_admission_timeout_joins_pending_send_before_capacity_reopens() {
|
||||
// This is the adapter's cancellation/join policy boundary used by the
|
||||
// captured protocol endpoint, not an execution-side-effect rollback.
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||
let (sender, mut receiver) = tokio::sync::mpsc::channel(1);
|
||||
sender.try_send(Method::ListPendingSubmissions).unwrap();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
struct PendingSendDrop(Arc<AtomicBool>);
|
||||
impl Drop for PendingSendDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let owned = PendingSendDrop(dropped.clone());
|
||||
let error = backend
|
||||
.run_cancellable_on_adapter_runtime(Duration::from_millis(5), async move {
|
||||
let _owned = owned;
|
||||
sender
|
||||
.send(Method::ListWorkers)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("was cancelled"), "{error}");
|
||||
assert!(
|
||||
dropped.load(Ordering::SeqCst),
|
||||
"must join task cancellation before returning"
|
||||
);
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(Method::ListPendingSubmissions)
|
||||
));
|
||||
assert!(
|
||||
matches!(
|
||||
receiver.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
|
||||
),
|
||||
"timeout must not leave an admission task capable of a late send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_execution_has_typed_input_rejection_and_safe_stop() {
|
||||
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap();
|
||||
|
||||
@@ -18,6 +18,15 @@ use crate::auth::{
|
||||
use crate::runtime::RuntimeWorkspaceScope;
|
||||
use crate::worker_backend::WorkspacePromptProjectionCache;
|
||||
|
||||
fn ticket_assignment_role_segment(role: server_api::TicketAssignmentRole) -> &'static str {
|
||||
match role {
|
||||
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
|
||||
server_api::TicketAssignmentRole::Worker => "worker",
|
||||
server_api::TicketAssignmentRole::Owner => "owner",
|
||||
server_api::TicketAssignmentRole::Contributor => "contributor",
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -590,6 +599,62 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
runtime.block_on(async move {
|
||||
match operation {
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id,
|
||||
role,
|
||||
request,
|
||||
} => {
|
||||
let role = ticket_assignment_role_segment(role);
|
||||
generated_workspace_response(
|
||||
client
|
||||
.ticket_assignment_set(
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.to_owned(),
|
||||
request,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentClear {
|
||||
ticket_id,
|
||||
role,
|
||||
query,
|
||||
} => {
|
||||
let role = ticket_assignment_role_segment(role);
|
||||
generated_workspace_response(
|
||||
client
|
||||
.ticket_assignment_clear(
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.to_owned(),
|
||||
query,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
|
||||
generated_workspace_response(
|
||||
client
|
||||
.ticket_assignments_page(workspace_id, ticket_id, query)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentDiagnostics {
|
||||
ticket_id,
|
||||
query,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.ticket_assignment_diagnostics(workspace_id, ticket_id, query)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
|
||||
generated_workspace_response(
|
||||
client
|
||||
.ticket_assignment_answer(workspace_id, ticket_id, request)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::WorkerControlList => {
|
||||
generated_workspace_response(
|
||||
client.worker_control_list(workspace_id).await,
|
||||
@@ -882,6 +947,125 @@ mod tests {
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ticket_assignment_transport_signs_exact_authoring_and_bounded_read_subjects() {
|
||||
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
let assignment_result =
|
||||
serde_json::json!({"workspace_id":"workspace-a", "ticket_id":"T-1", "assignment":null});
|
||||
let answer: server_api::TicketAssignmentAnswerRequest = serde_json::from_value(serde_json::json!({ "assignment_id": "delegation", "expected_last_event_id": "observed", "operation_key": "answer-op", "body": "User answer\ncontinued" })).unwrap();
|
||||
let cases = vec![
|
||||
(
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id: "T-1".into(),
|
||||
role: server_api::TicketAssignmentRole::Orchestrator,
|
||||
request: server_api::SetTicketRoleAssignmentRequest {
|
||||
operation_id: "assign-op".into(),
|
||||
expected_assignment_id: Some("delegation".into()),
|
||||
orchestrator_assignment_id: None,
|
||||
principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
},
|
||||
workdir_bindings: vec![],
|
||||
},
|
||||
},
|
||||
"PUT",
|
||||
"/api/w/workspace-a/tickets/T-1/assignments/orchestrator",
|
||||
assignment_result.clone(),
|
||||
),
|
||||
(
|
||||
WorkspaceServerOperation::TicketAssignmentClear {
|
||||
ticket_id: "T-1".into(),
|
||||
role: server_api::TicketAssignmentRole::Orchestrator,
|
||||
query: server_api::ClearTicketRoleAssignmentQuery {
|
||||
operation_id: Some("clear-op".into()),
|
||||
assignment_id: Some("delegation".into()),
|
||||
},
|
||||
},
|
||||
"DELETE",
|
||||
"/api/w/workspace-a/tickets/T-1/assignments/orchestrator?operation_id=clear-op&assignment_id=delegation",
|
||||
assignment_result,
|
||||
),
|
||||
(
|
||||
WorkspaceServerOperation::TicketAssignmentsPage {
|
||||
ticket_id: "T-1".into(),
|
||||
query: server_api::TicketAssignmentsQuery {
|
||||
after_assignment_id: Some("old id".into()),
|
||||
limit: Some(1),
|
||||
},
|
||||
},
|
||||
"GET",
|
||||
"/workspaces/workspace-a/tickets/T-1/assignment-tracking/assignments?after_assignment_id=old+id&limit=1",
|
||||
serde_json::json!({"items":[],"next_assignment_id":null}),
|
||||
),
|
||||
(
|
||||
WorkspaceServerOperation::TicketAssignmentDiagnostics {
|
||||
ticket_id: "T-1".into(),
|
||||
query: server_api::TicketAssignmentDiagnosticsQuery {
|
||||
assignment_id: "delegation".into(),
|
||||
after_input_event_id: Some("ticket-event:1".into()),
|
||||
after_start_operation_id: None,
|
||||
limit: Some(1),
|
||||
},
|
||||
},
|
||||
"GET",
|
||||
"/workspaces/workspace-a/tickets/T-1/assignment-diagnostics?assignment_id=delegation&after_input_event_id=ticket-event%3A1&limit=1",
|
||||
serde_json::json!({"assignment_id":"delegation","current":true,"input_origin_known":true,"actionable":true,"inputs":[],"starts":[],"next_input_event_id":null,"next_start_operation_id":null}),
|
||||
),
|
||||
(
|
||||
WorkspaceServerOperation::TicketAssignmentAnswer {
|
||||
ticket_id: "T-1".into(),
|
||||
request: answer.clone(),
|
||||
},
|
||||
"POST",
|
||||
"/workspaces/workspace-a/tickets/T-1/assignment-answer",
|
||||
serde_json::json!({"assignment_id":"delegation","input_event_id":"ticket-event:1","event_index":1}),
|
||||
),
|
||||
];
|
||||
for (operation, method, path, reply) in cases {
|
||||
let reply = reply.to_string();
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes();
|
||||
let (url, server) = binary_loopback(response);
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a")
|
||||
.with_runtime_request_source(&identity, "server-a");
|
||||
let result = client.execute_server_operation(operation).unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
let (head, body) = server.join().unwrap();
|
||||
assert!(
|
||||
head.starts_with(&format!("{method} {path} HTTP/1.1\r\n")),
|
||||
"unexpected request: {head}"
|
||||
);
|
||||
let token = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
.then(|| value.trim())
|
||||
})
|
||||
.unwrap();
|
||||
let digest = request_body_digest(&body);
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-a",
|
||||
audience: "server-a",
|
||||
workspace_id: "workspace-a",
|
||||
worker_id: Some("worker-a"),
|
||||
permission: WORKSPACE_REQUEST_PERMISSION,
|
||||
method,
|
||||
path,
|
||||
body_digest: &digest,
|
||||
now_unix: unix_now_seconds() as i64,
|
||||
};
|
||||
verify_runtime_request_source(token, &identity.public_key, &expected).unwrap();
|
||||
if method == "POST" {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
|
||||
serde_json::to_value(&answer).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
|
||||
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
//! Resource clones held by sessions and commands maintain that protection. Deletion invalidates
|
||||
//! the materialization generation, so recreating the same ID cannot activate old bindings.
|
||||
//!
|
||||
//! Deletion is a provider operation checking current identity, mounts, and remaining content.
|
||||
//! Partial-deletion witnesses are stored outside the tree. CleanupPending/unknown inventory status
|
||||
//! does not guarantee current cleanliness. Detailed inspection and remaining-content checks
|
||||
//! are limited to cleanup retries.
|
||||
//! Deletion checks current identity, mounts, Git cleanliness when needed, and surviving entry
|
||||
//! metadata. Durable metadata authority lives outside the tree; cleanup performs no content-hash
|
||||
//! scan or byte-equality verification. CleanupPending/unknown inventory checks saved identity only,
|
||||
//! not current cleanliness. Normal retries recheck safety; a failed attempt grants no deletion
|
||||
//! permission and does not permanently forbid normal reattachment to a usable same-identity checkout.
|
||||
|
||||
use crate::catalog::{
|
||||
MaterializerKind, RepositoryRefObservation, RepositoryRefObservationRequest,
|
||||
@@ -758,22 +759,12 @@ impl RuntimeGitMaterializer {
|
||||
// Frozen evidence normalization can write the record. Keep it inside
|
||||
// use admission so observational reads cannot race physical cleanup.
|
||||
let _use_lease = self.occupancy.acquire_use(working_directory_id)?;
|
||||
self.read_binding_record(working_directory_id, true)
|
||||
}
|
||||
|
||||
fn read_binding_for_cleanup(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
// Cleanup already owns exclusive admission and validates the original
|
||||
// bytes. Its witnessed status write performs any canonicalization.
|
||||
self.read_binding_record(working_directory_id, false)
|
||||
self.read_binding_record(working_directory_id)
|
||||
}
|
||||
|
||||
fn read_binding_record(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
migrate: bool,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||
let path = working_directory_root.join(MATERIALIZATION_RECORD);
|
||||
@@ -810,7 +801,7 @@ impl RuntimeGitMaterializer {
|
||||
"materialization evidence is invalid",
|
||||
)
|
||||
})?;
|
||||
if migrate && canonical != raw {
|
||||
if canonical != raw {
|
||||
let temporary = path.with_extension(format!("migration-{}.tmp", uuid::Uuid::now_v7()));
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
@@ -4800,9 +4791,16 @@ mod tests {
|
||||
materializer.read_binding(id).unwrap_err().code,
|
||||
"working_directory_cleanup_resource_busy"
|
||||
);
|
||||
materializer.read_binding_for_cleanup(id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
drop(cleanup);
|
||||
// Actual cleanup admission reads the legacy record without rewriting it
|
||||
// before change protection has admitted removal.
|
||||
fs::write(binding.root.join("README.md"), "preserve change").unwrap();
|
||||
assert_eq!(
|
||||
materializer.cleanup_working_directory(id).unwrap_err().code,
|
||||
"working_directory_cleanup_changes_present"
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
git(binding.root(), &["restore", "README.md"]);
|
||||
|
||||
materializer.read_binding(id).unwrap();
|
||||
let canonical: serde_json::Value =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,22 @@
|
||||
//! Summarization and reference-file selection by the compaction Internal Worker.
|
||||
//!
|
||||
//! Summary input is a bounded history projection; it does not rewrite the original history.
|
||||
//! Ordinary history uses a bounded overview plus exploration, while the previous Host-produced
|
||||
//! `CompactionSummary` is mandatory full-text input independent of overview and retention budgets.
|
||||
//! The replacement supersedes that old summary; it is excluded from both ordinary overview and
|
||||
//! retained tail. Other derived context remains subject to normal retention. Current tasks and
|
||||
//! recent progress support continuity, but do not replace original goals or completion conditions.
|
||||
//!
|
||||
//! The full inherited summary still counts against the hard compactor input cap. Before provider
|
||||
//! measurements exist, occupancy includes input, system prompt, and installed tools. The normal
|
||||
//! Worker interceptor enforces this cap, including persisted budget warnings; overflow fails
|
||||
//! without activating a replacement rather than clipping mandatory material. Provider failures
|
||||
//! likewise leave the source active. Estimates are not exact model-token guarantees.
|
||||
//!
|
||||
//! Results collect in [`CompactWorkerContext`]; [`crate::Worker::compact`] validates, saves,
|
||||
//! and activates them. Missing summaries or hard-limit violations fail compaction, unlike
|
||||
//! targets or warning-only thresholds.
|
||||
//! and activates them. Missing summaries and hard input/output limits fail compaction. An oversized
|
||||
//! output gets a bounded rewrite attempt. The assembled replacement's result-context threshold is
|
||||
//! advisory: a warning neither drops content nor bypasses subsequent request-time safety checks.
|
||||
//! Deterministic transport/retention tests do not prove a model's semantic retention of user intent.
|
||||
//!
|
||||
//! Auto-read accepts only UTF-8 text through the current WorkdirSession, checking scope and
|
||||
//! the aggregate budget. Images, binary data, and truncated content stay out of the body;
|
||||
@@ -372,6 +385,7 @@ pub(crate) struct CompactWorkerInterceptor {
|
||||
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
||||
warning_sent: AtomicBool,
|
||||
last_remaining_tokens: AtomicU64,
|
||||
request_shape: Option<(String, agen::tool_server::ToolServerHandle)>,
|
||||
}
|
||||
|
||||
impl CompactWorkerInterceptor {
|
||||
@@ -390,9 +404,21 @@ impl CompactWorkerInterceptor {
|
||||
on_warning,
|
||||
warning_sent: AtomicBool::new(false),
|
||||
last_remaining_tokens: AtomicU64::new(max_input_tokens),
|
||||
request_shape: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_request_shape(
|
||||
mut self,
|
||||
system_prompt: String,
|
||||
tools: agen::tool_server::ToolServerHandle,
|
||||
) -> Self {
|
||||
// Features install after engine configuration. This shared handle sees
|
||||
// the final installed tool definitions at the actual request boundary.
|
||||
self.request_shape = Some((system_prompt, tools));
|
||||
self
|
||||
}
|
||||
|
||||
fn maybe_emit_warning(&self, remaining: u64) -> Option<Item> {
|
||||
let warning_threshold = self.finish_warning_remaining_tokens;
|
||||
let reserve_threshold = self.final_reserve_tokens;
|
||||
@@ -405,7 +431,12 @@ impl CompactWorkerInterceptor {
|
||||
let message = format!(
|
||||
"compact worker context budget is low ({remaining}/{} tokens remaining). \
|
||||
Stop broad exploration now, read only if absolutely necessary, then call \
|
||||
`write_summary` with the final structured summary.",
|
||||
`write_summary` with the final structured summary. Preserve ongoing goals and \
|
||||
user directives from the full initial summary and segment. Compress duplicate \
|
||||
explanations and old work details first, not scope, prohibitions, completion \
|
||||
conditions or exception-reporting duties. Apply explicit later changes/retractions \
|
||||
and retain uncertainty; recovery procedures and recent progress do not replace \
|
||||
the original request.",
|
||||
self.max_input_tokens
|
||||
);
|
||||
if let Some(cb) = self.on_warning.as_ref() {
|
||||
@@ -426,17 +457,38 @@ impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
|
||||
let context = context.items;
|
||||
let records = self.usage_tracker.records();
|
||||
let estimate = agen::token_counter::total_tokens(context, &records);
|
||||
if estimate.tokens > self.max_input_tokens {
|
||||
// Before the first provider measurement, include the system and tool
|
||||
// shape as well as *all* mandatory summary/input bytes. Later exact
|
||||
// input measurements already include that shape; do not double charge.
|
||||
let shape_tokens = if records.is_empty() {
|
||||
self.request_shape.as_ref().map_or(0, |(system, tools)| {
|
||||
let tool_bytes = serde_json::to_vec(&tools.tool_definitions_sorted())
|
||||
.map(|bytes| bytes.len())
|
||||
.unwrap_or(0);
|
||||
estimate_tokens(system.len().saturating_add(tool_bytes))
|
||||
})
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let input_tokens = estimate.tokens.saturating_add(shape_tokens);
|
||||
if input_tokens > self.max_input_tokens {
|
||||
return Ok(PreRequestAction::Cancel(format!(
|
||||
"compact worker input occupancy exceeded {} tokens",
|
||||
"compact worker input occupancy (including mandatory full initial summary) exceeded {} tokens; no summary was truncated",
|
||||
self.max_input_tokens
|
||||
)));
|
||||
}
|
||||
|
||||
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
|
||||
let remaining = self.max_input_tokens.saturating_sub(input_tokens);
|
||||
self.last_remaining_tokens
|
||||
.store(remaining, Ordering::Release);
|
||||
if let Some(item) = self.maybe_emit_warning(remaining) {
|
||||
let warning_tokens =
|
||||
agen::token_counter::total_tokens(std::slice::from_ref(&item), &[]).tokens;
|
||||
if input_tokens.saturating_add(warning_tokens) > self.max_input_tokens {
|
||||
return Ok(PreRequestAction::Cancel(
|
||||
"compact worker input occupancy cannot fit budget warning alongside mandatory full input; no summary was truncated".into(),
|
||||
));
|
||||
}
|
||||
self.usage_tracker.note_request(context.len() + 1);
|
||||
return Ok(PreRequestAction::ContinueWith(vec![item]));
|
||||
}
|
||||
@@ -535,6 +587,85 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_worker_warning_policy_keeps_user_intent_and_fails_if_warning_cannot_fit() {
|
||||
let interceptor =
|
||||
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 100, 100, 0, None);
|
||||
let mut context = vec![Item::user_message("full input")];
|
||||
let result = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, PreRequestAction::Cancel(message) if message.contains("no summary was truncated"))
|
||||
);
|
||||
let interceptor =
|
||||
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 1000, 1000, 0, None);
|
||||
let result = interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let PreRequestAction::ContinueWith(items) = result else {
|
||||
panic!("expected persisted warning")
|
||||
};
|
||||
let warning = items[0].as_text().unwrap();
|
||||
for policy in [
|
||||
"full initial summary and segment",
|
||||
"prohibitions",
|
||||
"completion conditions",
|
||||
"exception-reporting duties",
|
||||
"changes/retractions",
|
||||
"uncertainty",
|
||||
"Compress duplicate",
|
||||
] {
|
||||
assert!(warning.contains(policy), "missing warning policy: {policy}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_worker_initial_request_counts_system_shape_but_measurements_do_not_double_count()
|
||||
{
|
||||
let tracker = Arc::new(UsageTracker::new());
|
||||
let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 100, 0, 0, None)
|
||||
.with_request_shape(
|
||||
"system".repeat(100),
|
||||
agen::tool_server::ToolServerHandle::default(),
|
||||
);
|
||||
let mut context = vec![Item::user_message("full input")];
|
||||
assert!(matches!(
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Cancel(_)
|
||||
));
|
||||
tracker.note_request(context.len());
|
||||
tracker.record_usage(&make_usage(50));
|
||||
assert!(matches!(
|
||||
interceptor
|
||||
.pre_llm_request(PreLlmRequestContext::<()> {
|
||||
invocation: Default::default(),
|
||||
items: &mut context,
|
||||
history: &[],
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
PreRequestAction::Continue
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_worker_interceptor_warns_before_hard_cap() {
|
||||
let tracker = Arc::new(UsageTracker::new());
|
||||
@@ -542,8 +673,8 @@ mod tests {
|
||||
let captured = warnings.clone();
|
||||
let interceptor = CompactWorkerInterceptor::new(
|
||||
tracker.clone(),
|
||||
150,
|
||||
60,
|
||||
500,
|
||||
410,
|
||||
20,
|
||||
Some(Arc::new(move |message| {
|
||||
captured.lock().unwrap().push(message);
|
||||
|
||||
@@ -574,6 +574,7 @@ where
|
||||
attempt.instance_id, attempt.attempt_id
|
||||
)),
|
||||
max_turns: Some(12),
|
||||
compaction_budget: None,
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
|
||||
@@ -473,6 +473,9 @@ pub struct WorkerLifecycleWorkdirAttachment {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerLifecycleSpawnRequest {
|
||||
/// Observed delegation required for Orchestrator Ticket starts.
|
||||
pub orchestrator_assignment_id: Option<String>,
|
||||
|
||||
pub runtime_id: String,
|
||||
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
|
||||
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
|
||||
@@ -494,6 +497,7 @@ fn workspace_worker_create_request(
|
||||
let ticket_assignment = match (request.ticket_id, request.operation_id) {
|
||||
(Some(ticket_id), Some(operation_id)) => {
|
||||
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||
orchestrator_assignment_id: request.orchestrator_assignment_id,
|
||||
ticket_id,
|
||||
operation_id,
|
||||
})
|
||||
@@ -729,6 +733,9 @@ struct WorkerSpawnInput {
|
||||
/// neither the profile nor an initial Flow segment grants review authority.
|
||||
#[serde(default)]
|
||||
ticket_id: Option<String>,
|
||||
/// Observed current Orchestrator delegation, required for a Ticket start by the Orchestrator.
|
||||
#[serde(default)]
|
||||
orchestrator_assignment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
/// Normal typed initial user submission delivered after spawn. An empty
|
||||
@@ -913,6 +920,10 @@ impl Tool for WorkspaceWorkerTool {
|
||||
.transpose()?;
|
||||
self.control
|
||||
.spawn_worker(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: input
|
||||
.orchestrator_assignment_id
|
||||
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
|
||||
.transpose()?,
|
||||
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
|
||||
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
|
||||
alias: INITIAL_WORKDIR_ALIAS.to_string(),
|
||||
@@ -1895,6 +1906,7 @@ mod tests {
|
||||
#[test]
|
||||
fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() {
|
||||
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: None,
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
workdir_attachments: vec![
|
||||
WorkerLifecycleWorkdirAttachment {
|
||||
@@ -1954,6 +1966,7 @@ mod tests {
|
||||
#[test]
|
||||
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
|
||||
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: Some("delegation-742".into()),
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
workdir_attachments: Vec::new(),
|
||||
profile: "project:ticket-worker".to_string(),
|
||||
@@ -1971,6 +1984,7 @@ mod tests {
|
||||
value["ticket_assignment"],
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-719",
|
||||
"orchestrator_assignment_id": "delegation-742",
|
||||
"operation_id": "spawn-ticket-worker:T-719:call-1",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -574,6 +574,7 @@ impl MemoryLifecycleTask {
|
||||
.extraction
|
||||
.worker_max_turns
|
||||
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||
compaction_budget: None,
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &[
|
||||
|
||||
@@ -388,6 +388,7 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
input,
|
||||
cache_key: None,
|
||||
max_turns: Some(EDITOR_MAX_TURNS),
|
||||
compaction_budget: None,
|
||||
engine_configurator: None,
|
||||
features,
|
||||
required_tools: &["SubmitMemorySurface"],
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -78,6 +78,9 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
|
||||
#[serde(default)]
|
||||
orchestrator_assignment_id: Option<String>,
|
||||
runtime_id: String,
|
||||
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
|
||||
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
|
||||
@@ -132,6 +135,10 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
let response = self
|
||||
.worker_service
|
||||
.spawn(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: input
|
||||
.orchestrator_assignment_id
|
||||
.map(|id| authority_id(id, "orchestrator_assignment_id"))
|
||||
.transpose()?,
|
||||
runtime_id,
|
||||
workdir_attachments,
|
||||
profile,
|
||||
@@ -247,13 +254,13 @@ fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ticket::{TicketError, TicketWorkflowState};
|
||||
use ticket::{TicketError, TicketState};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
struct FixedTicketService(TicketWorkflowState);
|
||||
struct FixedTicketService(TicketState);
|
||||
|
||||
impl TicketService for FixedTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
@@ -295,7 +302,7 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
|
||||
fn tool(service: Arc<RecordingService>, state: TicketState) -> SpawnTicketWorkerTool {
|
||||
SpawnTicketWorkerTool {
|
||||
ticket_service: Arc::new(FixedTicketService(state)),
|
||||
worker_service: service,
|
||||
@@ -307,6 +314,7 @@ mod tests {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let mut input = input();
|
||||
input["flow"] = serde_json::json!("project:investigation");
|
||||
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
|
||||
input["workdir_attachments"] = serde_json::json!([
|
||||
{
|
||||
"alias": "checkout",
|
||||
@@ -315,7 +323,7 @@ mod tests {
|
||||
},
|
||||
{"alias": "docs", "working_directory_id": "workdir-2"}
|
||||
]);
|
||||
let output = tool(service.clone(), TicketWorkflowState::Queued)
|
||||
let output = tool(service.clone(), TicketState::Open)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||
@@ -327,6 +335,10 @@ mod tests {
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert_eq!(request.runtime_id, "runtime-1");
|
||||
assert_eq!(
|
||||
request.orchestrator_assignment_id.as_deref(),
|
||||
Some("delegation-742")
|
||||
);
|
||||
assert_eq!(request.profile, "project:ticket-worker");
|
||||
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
||||
assert_eq!(
|
||||
@@ -372,7 +384,7 @@ mod tests {
|
||||
input["workdir_attachments"] = serde_json::json!([]);
|
||||
input["flow"] = serde_json::Value::Null;
|
||||
}
|
||||
tool(service.clone(), TicketWorkflowState::Queued)
|
||||
tool(service.clone(), TicketState::Open)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-empty", "batch-1", 0),
|
||||
@@ -395,7 +407,7 @@ mod tests {
|
||||
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
|
||||
Arc::new(FixedTicketService(TicketState::Open)),
|
||||
service.clone(),
|
||||
);
|
||||
let (meta, tool) = definition();
|
||||
@@ -434,33 +446,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
response: Some(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "Backend rejected Ticket claim".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let error = tool(service.clone(), TicketWorkflowState::Planning)
|
||||
.execute(
|
||||
&input().to_string(),
|
||||
ToolExecutionContext::new("call-claim", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(service.requests.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("HTTP 409: Backend rejected Ticket claim")
|
||||
);
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() {
|
||||
for state in [TicketState::Open, TicketState::Closed] {
|
||||
let service = Arc::new(RecordingService {
|
||||
response: Some(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "Backend rejected Ticket claim".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let error = tool(service.clone(), state)
|
||||
.execute(
|
||||
&input().to_string(),
|
||||
ToolExecutionContext::new("call-claim", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(service.requests.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("HTTP 409: Backend rejected Ticket claim")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let tool = tool(service.clone(), TicketWorkflowState::Queued);
|
||||
let tool = tool(service.clone(), TicketState::Open);
|
||||
let cases = [
|
||||
(
|
||||
"profile",
|
||||
@@ -473,6 +487,16 @@ mod tests {
|
||||
"initial_request must not be empty",
|
||||
),
|
||||
("flow", serde_json::json!(" "), "flow must not be empty"),
|
||||
(
|
||||
"orchestrator_assignment_id",
|
||||
serde_json::json!("http://invalid/id"),
|
||||
"orchestrator_assignment_id must be an authority id",
|
||||
),
|
||||
(
|
||||
"orchestrator_assignment_id",
|
||||
serde_json::json!(" "),
|
||||
"orchestrator_assignment_id must not be empty",
|
||||
),
|
||||
(
|
||||
"runtime_id",
|
||||
serde_json::json!("runtime/1"),
|
||||
@@ -603,7 +627,7 @@ mod tests {
|
||||
#[test]
|
||||
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||
Arc::new(FixedTicketService(TicketState::Open)),
|
||||
Arc::new(RecordingService::default()),
|
||||
);
|
||||
let (meta, _) = definition();
|
||||
|
||||
@@ -13,7 +13,12 @@ pub(super) struct ModelTicketQueryResponse {
|
||||
struct ModelTicketQueryItem {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
state: ticket::TicketState,
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
@@ -38,11 +43,17 @@ pub(super) struct ModelTicketDetail {
|
||||
content_digest: String,
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
state: ticket::TicketState,
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
|
||||
thread: Vec<ModelTicketEvent>,
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
@@ -89,13 +100,36 @@ pub(super) struct ModelObjectiveDetail {
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelAssignmentTracking {
|
||||
assignment_id: String,
|
||||
role: String,
|
||||
principal: ticket::TicketAssignmentPrincipalSnapshot,
|
||||
assigned_by: String,
|
||||
assigned_at: String,
|
||||
release: Option<ticket::TicketAssignmentRelease>,
|
||||
current: bool,
|
||||
tracking: Option<ticket::TicketAssignmentTracking>,
|
||||
last_event_id: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
migration_diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketTagChange {
|
||||
before: Vec<ticket::WorkspaceTag>,
|
||||
after: Vec<ticket::WorkspaceTag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tag_changes: Option<ModelTicketTagChange>,
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
@@ -216,6 +250,16 @@ struct ModelObjectiveEventPage {
|
||||
has_more: bool,
|
||||
}
|
||||
|
||||
fn typed_field<T: serde::de::DeserializeOwned>(
|
||||
root: &Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<T, String> {
|
||||
let value = root
|
||||
.get(key)
|
||||
.ok_or_else(|| format!("Ticket projection requires {key}"))?;
|
||||
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
|
||||
}
|
||||
|
||||
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
|
||||
let root = object(&value, "Ticket query response")?;
|
||||
let page = object_field(root, "page")?;
|
||||
@@ -235,7 +279,12 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
state: typed_field(item, "state")?,
|
||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||
current_closure: typed_field(item, "current_closure")?,
|
||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||
assignment_tracking: project_assignment_tracking(item)?,
|
||||
tags: project_tags(item)?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
@@ -286,7 +335,13 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
content_digest: string_field(root, "content_digest")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
state: typed_field(root, "state")?,
|
||||
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
||||
current_closure: typed_field(root, "current_closure")?,
|
||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||
closure_history: typed_field(root, "closure_history")?,
|
||||
assignment_tracking: project_assignment_tracking(root)?,
|
||||
tags: project_tags(root)?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
@@ -394,7 +449,27 @@ fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") {
|
||||
let attributes = event
|
||||
.get("attributes")
|
||||
.ok_or("missing tag audit attributes")?;
|
||||
let snapshot = |field| -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let encoded = attributes
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing tag audit snapshot")?;
|
||||
serde_json::from_str(encoded)
|
||||
.map_err(|error| format!("invalid tag audit snapshot: {error}"))
|
||||
};
|
||||
Some(ModelTicketTagChange {
|
||||
before: snapshot("tags_before")?,
|
||||
after: snapshot("tags_after")?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ModelTicketEvent {
|
||||
tag_changes,
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
@@ -616,6 +691,54 @@ fn object_field<'a>(
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn project_assignment_tracking(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<Vec<ModelAssignmentTracking>, String> {
|
||||
let values = array_field(object, "assignment_tracking")?;
|
||||
if values.len() > 1 {
|
||||
return Err("Ticket has more than one current Orchestrator delegation".into());
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let projection: ticket::TicketAssignmentTrackingProjection =
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid assignment tracking: {error}"))?;
|
||||
Ok(ModelAssignmentTracking {
|
||||
assignment_id: projection.assignment_id,
|
||||
role: projection.role,
|
||||
principal: projection.principal,
|
||||
assigned_by: projection.assigned_by,
|
||||
assigned_at: projection.assigned_at,
|
||||
release: projection.release,
|
||||
current: projection.current,
|
||||
tracking: projection.tracking,
|
||||
last_event_id: projection.last_event_id,
|
||||
updated_at: projection.updated_at,
|
||||
migration_diagnostic: projection.migration_diagnostic,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let values = array_field(object, "tags")?;
|
||||
if values.len() > 100 {
|
||||
return Err("Ticket projection exceeds 100 tags".into());
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid Workspace tag: {error}"))?;
|
||||
if tag.deleted {
|
||||
return Err("current Ticket projection contains a deleted tag".into());
|
||||
}
|
||||
Ok(tag)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
@@ -694,16 +817,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] {
|
||||
for state in ["open", "closed"] {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null,
|
||||
@@ -724,6 +841,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_ticket_projection_preserves_closure_observation_history_and_legacy_handoff() {
|
||||
let history = json!([
|
||||
{
|
||||
"operation_key": "close-1", "previous_closure_operation_key": null,
|
||||
"action": {"action": "close", "reason": "not_planned"},
|
||||
"explanation": "Not proceeding", "references": [{"kind": "ticket", "target": "T-12"}],
|
||||
"author": "workspace-user:alice", "at": "2026-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"operation_key": "reopen-1", "previous_closure_operation_key": "close-1",
|
||||
"action": {"action": "reopen"}, "explanation": "New information",
|
||||
"references": [], "author": "workspace-user:bob", "at": "2026-01-02T00:00:00Z"
|
||||
}
|
||||
]);
|
||||
let legacy = json!({
|
||||
"workflow_state": "queued", "workflow_state_explicit": true, "status": "open",
|
||||
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
|
||||
});
|
||||
let response = json!({
|
||||
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
|
||||
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
|
||||
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
|
||||
"closure_history": history, "legacy_handoff": legacy,
|
||||
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null
|
||||
});
|
||||
let projected =
|
||||
serde_json::to_value(project_ticket_detail(response.clone()).unwrap()).unwrap();
|
||||
assert_eq!(projected["state"], "open");
|
||||
assert_eq!(projected["closure_operation_key"], "close-1");
|
||||
assert_eq!(projected["current_closure"], Value::Null);
|
||||
assert_eq!(projected["closure_history"], history);
|
||||
assert_eq!(projected["legacy_handoff"], legacy);
|
||||
assert_eq!(projected["tags"], response["tags"]);
|
||||
assert_eq!(projected["content_digest"], "content-digest");
|
||||
|
||||
// Missing observations must not silently become null: the model must be
|
||||
// able to distinguish an observed never-closed case from unknown data.
|
||||
let mut missing = response;
|
||||
missing
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("closure_operation_key");
|
||||
assert!(
|
||||
project_ticket_detail(missing)
|
||||
.unwrap_err()
|
||||
.contains("closure_operation_key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
@@ -738,9 +906,9 @@ mod tests {
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496",
|
||||
"resource_key": "T-496", "assignment_tracking": [], "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "done",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
}],
|
||||
"events": [{
|
||||
@@ -769,9 +937,10 @@ mod tests {
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543",
|
||||
"resource_key": "T-543", "assignment_tracking": [], "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "inprogress",
|
||||
"state": "open",
|
||||
"closure_operation_key": null, "current_closure": null, "legacy_handoff": null,
|
||||
"readiness": null,
|
||||
"priority": "high",
|
||||
"created_at": null,
|
||||
@@ -840,11 +1009,12 @@ mod tests {
|
||||
fn ticket_detail_projection_accepts_current_blocker_shape() {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"id": "internal-ticket",
|
||||
"resource_key": "T-588",
|
||||
"resource_key": "T-588", "assignment_tracking": [], "tags": [],
|
||||
"content_digest": "ticket-content-digest",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"title": "Queued Submit",
|
||||
"body": "Body",
|
||||
"state": "planning",
|
||||
"state": "open",
|
||||
"readiness": null,
|
||||
"priority": "P2",
|
||||
"created_at": "2026-09-03T00:00:00Z",
|
||||
@@ -859,7 +1029,7 @@ mod tests {
|
||||
"reason_kind": "depends_on",
|
||||
"relation_kind": "depends_on",
|
||||
"note": "required foundation",
|
||||
"blocking_state": "planning"
|
||||
"blocking_state": "open"
|
||||
}],
|
||||
"notices": []
|
||||
},
|
||||
@@ -898,7 +1068,7 @@ mod tests {
|
||||
json!([{
|
||||
"ticket": "T-584",
|
||||
"kind": "depends_on",
|
||||
"state": "planning"
|
||||
"state": "open"
|
||||
}])
|
||||
);
|
||||
assert!(!projected.to_string().contains("internal-blocker"));
|
||||
@@ -943,11 +1113,12 @@ mod tests {
|
||||
"review_excerpt": "Reviewed source"
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716",
|
||||
"resource_key": "T-716", "assignment_tracking": [], "tags": [],
|
||||
"content_digest": "current-ticket-content-digest",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"title": "Source evidence",
|
||||
"body": "Body",
|
||||
"state": if merged { "done" } else { "inprogress" },
|
||||
"state": "open",
|
||||
"events": [],
|
||||
"linked_objectives": [],
|
||||
"assignments": [],
|
||||
@@ -1123,4 +1294,132 @@ mod tests {
|
||||
.expect_err("missing W-key must fail");
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
#[test]
|
||||
fn ticket_query_and_detail_preserve_case_classification_and_delegation_independently() {
|
||||
let tracking = json!({"workspace_id":"ws", "ticket_id":"internal-id", "assignment_id":"delegation", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"workspace-user:alice", "assigned_at":"2026-01-01T00:00:00Z", "release":null, "current":true, "last_event_id":"reported", "updated_at":"2026-01-01T00:00:00Z", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"Human answer", "progress":null, "next_action":"Compare the response", "result":null}});
|
||||
let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false});
|
||||
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
|
||||
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
|
||||
let detail = json!({
|
||||
"resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1",
|
||||
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
|
||||
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
|
||||
});
|
||||
let mut item = detail.clone();
|
||||
item["matched_fields"] = json!([]);
|
||||
item["linked_objective_keys"] = json!([]);
|
||||
for field in [
|
||||
"relation_count",
|
||||
"blocker_count",
|
||||
"unresolved_blocker_count",
|
||||
"unresolved_review_count",
|
||||
] {
|
||||
item[field] = json!(0);
|
||||
}
|
||||
let projected_detail =
|
||||
serde_json::to_value(project_ticket_detail(detail.clone()).unwrap()).unwrap();
|
||||
let projected_query = serde_json::to_value(
|
||||
project_ticket_query(
|
||||
json!({"items":[item.clone()], "page":{"has_more":false, "next_cursor":null}}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for field in [
|
||||
"tags",
|
||||
"state",
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
] {
|
||||
assert_eq!(projected_detail[field], detail[field], "detail {field}");
|
||||
assert_eq!(
|
||||
projected_query["tickets"][0][field], detail[field],
|
||||
"query {field}"
|
||||
);
|
||||
}
|
||||
for projected in [&projected_detail, &projected_query["tickets"][0]] {
|
||||
let actual = &projected["assignment_tracking"][0];
|
||||
assert_eq!(actual["assignment_id"], "delegation");
|
||||
assert_eq!(actual["last_event_id"], "reported");
|
||||
assert_eq!(actual["tracking"], tracking["tracking"]);
|
||||
assert!(actual.get("workspace_id").is_none());
|
||||
assert!(actual.get("ticket_id").is_none());
|
||||
}
|
||||
assert_eq!(projected_detail["closure_history"], history);
|
||||
assert_eq!(projected_detail["content_digest"], "content-1");
|
||||
// Unknown observations must not be replaced with empty/null values.
|
||||
for field in [
|
||||
"tags",
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
"state",
|
||||
] {
|
||||
let mut missing_detail = detail.clone();
|
||||
missing_detail.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
project_ticket_detail(missing_detail)
|
||||
.unwrap_err()
|
||||
.contains(field),
|
||||
"detail {field}"
|
||||
);
|
||||
let mut missing_item = item.clone();
|
||||
missing_item.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
project_ticket_query(
|
||||
json!({"items":[missing_item], "page":{"has_more":false, "next_cursor":null}})
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains(field),
|
||||
"query {field}"
|
||||
);
|
||||
}
|
||||
for invalid_tags in [
|
||||
json!([{"id":"tag-1"}]),
|
||||
json!(vec![detail["tags"][0].clone(); 101]),
|
||||
{
|
||||
let mut deleted = detail["tags"].clone();
|
||||
deleted[0]["deleted"] = json!(true);
|
||||
deleted
|
||||
},
|
||||
] {
|
||||
let mut malformed = detail.clone();
|
||||
malformed["tags"] = invalid_tags;
|
||||
assert!(project_ticket_detail(malformed).is_err());
|
||||
}
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"unknown",
|
||||
] {
|
||||
let mut malformed = detail.clone();
|
||||
malformed["state"] = json!(state);
|
||||
assert!(
|
||||
project_ticket_detail(malformed)
|
||||
.unwrap_err()
|
||||
.contains("state"),
|
||||
"{state}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
||||
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
||||
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
|
||||
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
|
||||
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
|
||||
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
|
||||
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
|
||||
assert!(
|
||||
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
|
||||
.unwrap()
|
||||
.get("tag_changes")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
//! Public delegation operations over shared host-bound typed Workspace forwarding.
|
||||
//! Source/actor authorization and assignment/receipt authority remain Backend-owned.
|
||||
//!
|
||||
//! Assign targets responsibility, not Worker execution: the target principal is not the actor.
|
||||
//! The nullable expected assignment must be explicitly observed, never omitted as unknown.
|
||||
//! A new reissue creates a delegation; an exact retry retains its operation identity and full input.
|
||||
//! Saved delegation plus notification failure is not failed Assign. Reread and reconcile stale or
|
||||
//! unknown outcomes rather than silently generating a new operation key or using another transport.
|
||||
//!
|
||||
//! Answers continue waiting/inprogress against the same assignment/event CAS without reporting
|
||||
//! progress; done needs explicit reissue. Retained assignments and sanitized diagnostics are reads,
|
||||
//! not mutation authority. Delivery/start cursors are independent of handling history; Runtime
|
||||
//! acceptance, Worker liveness, and the absence of diagnostics never attest model processing.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
use super::*;
|
||||
use crate::worker::WorkspaceServerOperation;
|
||||
|
||||
pub(super) const TOOL_NAMES: &[&str] = &[
|
||||
"TicketOrchestratorAssign",
|
||||
"TicketOrchestratorClear",
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
];
|
||||
pub(super) const READ_TOOL_NAMES: &[&str] =
|
||||
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum PublicAssignmentKind {
|
||||
Assign,
|
||||
Clear,
|
||||
Assignments,
|
||||
Diagnostics,
|
||||
Answer,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AssignInput {
|
||||
ticket: String,
|
||||
/// Target principal, not the authenticated actor. Backend validates role/principal pairing.
|
||||
principal: server_api::TicketAssignmentPrincipal,
|
||||
/// Observed current assignment ID, or explicitly null for an unassigned Ticket.
|
||||
#[serde(deserialize_with = "observed_assignment")]
|
||||
expected_assignment_id: Option<String>,
|
||||
/// Stable identity of this Assign/reissue; exact retries keep the same full input.
|
||||
operation_id: String,
|
||||
}
|
||||
fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
Option::<String>::deserialize(d)
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ClearInput {
|
||||
ticket: String,
|
||||
/// Observed exact current delegation to release, not Worker identity/status.
|
||||
assignment_id: String,
|
||||
operation_id: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AssignmentsInput {
|
||||
ticket: String,
|
||||
#[serde(flatten)]
|
||||
query: server_api::TicketAssignmentsQuery,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DiagnosticsInput {
|
||||
ticket: String,
|
||||
#[serde(flatten)]
|
||||
query: server_api::TicketAssignmentDiagnosticsQuery,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AnswerInput {
|
||||
ticket: String,
|
||||
#[serde(flatten)]
|
||||
request: server_api::TicketAssignmentAnswerRequest,
|
||||
}
|
||||
|
||||
impl PublicAssignmentKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Assign => TOOL_NAMES[0],
|
||||
Self::Clear => TOOL_NAMES[1],
|
||||
Self::Assignments => TOOL_NAMES[2],
|
||||
Self::Diagnostics => TOOL_NAMES[3],
|
||||
Self::Answer => TOOL_NAMES[4],
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::Assign => {
|
||||
let mut schema = json!(schemars::schema_for!(AssignInput));
|
||||
let required = schema["required"]
|
||||
.as_array_mut()
|
||||
.expect("Assign object schema");
|
||||
if !required
|
||||
.iter()
|
||||
.any(|field| field == "expected_assignment_id")
|
||||
{
|
||||
required.push(json!("expected_assignment_id"));
|
||||
}
|
||||
schema
|
||||
}
|
||||
Self::Clear => json!(schemars::schema_for!(ClearInput)),
|
||||
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
|
||||
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
|
||||
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketOrchestratorAssign" => {
|
||||
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
|
||||
}
|
||||
"TicketOrchestratorClear" => {
|
||||
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
|
||||
}
|
||||
"TicketAssignmentsQuery" => {
|
||||
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
|
||||
}
|
||||
"TicketAssignmentDiagnostics" => {
|
||||
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
|
||||
}
|
||||
"TicketAssignmentAnswer" => {
|
||||
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
|
||||
}
|
||||
_ => unreachable!("unknown public assignment tool"),
|
||||
}
|
||||
}
|
||||
fn identity(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must be nonempty, control-free and at most 1024 bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn page(limit: Option<usize>) -> Result<usize, ToolError> {
|
||||
let limit = limit.unwrap_or(100);
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be between 1 and 100".into(),
|
||||
));
|
||||
}
|
||||
Ok(limit)
|
||||
}
|
||||
fn optional_identity(name: &str, value: &Option<String>) -> Result<(), ToolError> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(|value| identity(name, value))
|
||||
.transpose()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
struct PublicAssignmentTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: PublicAssignmentKind,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Tool for PublicAssignmentTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Assignment tools require Backend Workspace identity; no local fallback".into(),
|
||||
)
|
||||
})?;
|
||||
let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
|
||||
let operation = match self.kind {
|
||||
PublicAssignmentKind::Assign => {
|
||||
let input: AssignInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
identity("operation_id", &input.operation_id)?;
|
||||
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
|
||||
match &input.principal {
|
||||
server_api::TicketAssignmentPrincipal::User { account_id } => {
|
||||
identity("account_id", account_id)?
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
identity("runtime_id", runtime_id)?;
|
||||
identity("worker_id", worker_id)?;
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
identity("agent_key", agent_key)?
|
||||
}
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id: input.ticket,
|
||||
role: server_api::TicketAssignmentRole::Orchestrator,
|
||||
request: server_api::SetTicketRoleAssignmentRequest {
|
||||
orchestrator_assignment_id: None,
|
||||
operation_id: input.operation_id,
|
||||
principal: input.principal,
|
||||
expected_assignment_id: input.expected_assignment_id,
|
||||
workdir_bindings: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::Clear => {
|
||||
let input: ClearInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
identity("assignment_id", &input.assignment_id)?;
|
||||
identity("operation_id", &input.operation_id)?;
|
||||
WorkspaceServerOperation::TicketAssignmentClear {
|
||||
ticket_id: input.ticket,
|
||||
role: server_api::TicketAssignmentRole::Orchestrator,
|
||||
query: server_api::ClearTicketRoleAssignmentQuery {
|
||||
operation_id: Some(input.operation_id),
|
||||
assignment_id: Some(input.assignment_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::Assignments => {
|
||||
let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
optional_identity("after_assignment_id", &input.query.after_assignment_id)?;
|
||||
input.query.limit = Some(page(input.query.limit)?);
|
||||
WorkspaceServerOperation::TicketAssignmentsPage {
|
||||
ticket_id: input.ticket,
|
||||
query: input.query,
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::Diagnostics => {
|
||||
let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
identity("assignment_id", &input.query.assignment_id)?;
|
||||
optional_identity("after_input_event_id", &input.query.after_input_event_id)?;
|
||||
optional_identity(
|
||||
"after_start_operation_id",
|
||||
&input.query.after_start_operation_id,
|
||||
)?;
|
||||
input.query.limit = Some(page(input.query.limit)?);
|
||||
WorkspaceServerOperation::TicketAssignmentDiagnostics {
|
||||
ticket_id: input.ticket,
|
||||
query: input.query,
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::Answer => {
|
||||
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
for (name, value) in [
|
||||
("assignment_id", &input.request.0.assignment_id),
|
||||
(
|
||||
"expected_last_event_id",
|
||||
&input.request.0.expected_last_event_id,
|
||||
),
|
||||
("operation_key", &input.request.0.operation_key),
|
||||
] {
|
||||
identity(name, value)?;
|
||||
}
|
||||
let body = &input.request.0.body;
|
||||
if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
|
||||
));
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentAnswer {
|
||||
ticket_id: input.ticket,
|
||||
request: input.request,
|
||||
}
|
||||
}
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.execute_server_operation(operation)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Assignment request failed with HTTP status {}: {}",
|
||||
response.status,
|
||||
response.body.chars().take(1024).collect::<String>()
|
||||
)));
|
||||
}
|
||||
if response.body.len() > 1024 * 1024 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Assignment response exceeds 1 MiB; request a smaller page".into(),
|
||||
));
|
||||
}
|
||||
// Decode the actual public DTO and serialize only its declared fields; no raw
|
||||
// backend payload/credentials or alternative result authority reaches the model.
|
||||
let decode = |error: serde_json::Error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Invalid Backend assignment response (outcome unknown after dispatch): {error}"
|
||||
))
|
||||
};
|
||||
let content = match self.kind {
|
||||
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value(
|
||||
serde_json::from_str::<server_api::TicketRoleAssignmentMutationResponse>(
|
||||
&response.body,
|
||||
)
|
||||
.map_err(decode)?,
|
||||
),
|
||||
PublicAssignmentKind::Assignments => {
|
||||
let result: server_api::TicketAssignmentsPage =
|
||||
serde_json::from_str(&response.body).map_err(decode)?;
|
||||
if result.0.items.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Assignment page exceeds 100 items".into(),
|
||||
));
|
||||
}
|
||||
serde_json::to_value(result)
|
||||
}
|
||||
PublicAssignmentKind::Diagnostics => {
|
||||
let result: server_api::TicketAssignmentDiagnosticsPage =
|
||||
serde_json::from_str(&response.body).map_err(decode)?;
|
||||
if result.inputs.len() > 100 || result.starts.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Diagnostic page exceeds 100 items".into(),
|
||||
));
|
||||
}
|
||||
serde_json::to_value(result)
|
||||
}
|
||||
PublicAssignmentKind::Answer => serde_json::to_value(
|
||||
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
|
||||
.map_err(decode)?,
|
||||
),
|
||||
}
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) fn definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: PublicAssignmentKind,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(PublicAssignmentTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Transport {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
response: Result<WorkspaceResponse, String>,
|
||||
available: bool,
|
||||
}
|
||||
impl WorkspaceClient for Transport {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.available.then_some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"assignment-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.response.clone().map_err(WorkspaceClientError::Request)
|
||||
}
|
||||
}
|
||||
fn transport(status: u16, body: Value) -> Arc<Transport> {
|
||||
Arc::new(Transport {
|
||||
requests: Mutex::new(vec![]),
|
||||
available: true,
|
||||
response: Ok(WorkspaceResponse {
|
||||
status,
|
||||
body: body.to_string(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
|
||||
vec![
|
||||
(
|
||||
PublicAssignmentKind::Assign,
|
||||
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
|
||||
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::Clear,
|
||||
json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}),
|
||||
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::Assignments,
|
||||
json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}),
|
||||
json!({"items":[], "next_assignment_id":null}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::Diagnostics,
|
||||
json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}),
|
||||
json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::Answer,
|
||||
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
|
||||
json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}),
|
||||
),
|
||||
]
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() {
|
||||
for (kind, input, response) in cases() {
|
||||
let client = transport(200, response);
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(
|
||||
schema.is_valid(&input),
|
||||
"{}: {}",
|
||||
meta.name,
|
||||
meta.input_schema
|
||||
);
|
||||
for field in meta.input_schema["required"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let mut missing = input.clone();
|
||||
missing
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field.as_str().unwrap());
|
||||
assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name);
|
||||
assert!(matches!(
|
||||
tool.execute(&missing.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for actor_claim in [
|
||||
"actor",
|
||||
"author",
|
||||
"role",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"authentication_binding_id",
|
||||
] {
|
||||
let mut forged = input.clone();
|
||||
forged[actor_claim] = json!("authority");
|
||||
assert!(
|
||||
!schema.is_valid(&forged),
|
||||
"{} accepts {actor_claim}",
|
||||
meta.name
|
||||
);
|
||||
assert!(matches!(
|
||||
tool.execute(&forged.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
|
||||
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
|
||||
matches!(
|
||||
kind,
|
||||
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear
|
||||
)
|
||||
}) {
|
||||
if matches!(kind, PublicAssignmentKind::Assign) {
|
||||
input["expected_assignment_id"] = json!("previous-done");
|
||||
}
|
||||
let client = transport(200, response);
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
for _ in 0..2 {
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0], requests[1]);
|
||||
assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator")
|
||||
);
|
||||
match kind {
|
||||
PublicAssignmentKind::Assign => {
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Put);
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["expected_assignment_id"], "previous-done");
|
||||
assert_eq!(body["operation_id"], input["operation_id"]);
|
||||
assert_eq!(body["principal"], input["principal"]);
|
||||
assert!(body.get("actor").is_none());
|
||||
assert!(body.get("orchestrator_assignment_id").is_none());
|
||||
assert_eq!(body["workdir_bindings"], json!([]));
|
||||
}
|
||||
PublicAssignmentKind::Clear => {
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete);
|
||||
assert!(requests[0].path.contains("assignment_id=delegation"));
|
||||
assert!(requests[0].path.contains("operation_id=clear-1"));
|
||||
assert!(requests[0].body.is_none());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() {
|
||||
let (kind, input, response) = cases().pop().unwrap();
|
||||
let client = transport(200, response.clone());
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
for _ in 0..2 {
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap(),
|
||||
response
|
||||
);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests[0], requests[1]);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/workspaces/workspace/tickets/T-743/assignment-answer"
|
||||
);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
let mut expected = input;
|
||||
expected.as_object_mut().unwrap().remove("ticket");
|
||||
assert_eq!(body, expected);
|
||||
assert!(body.get("tracking").is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() {
|
||||
for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| {
|
||||
matches!(
|
||||
kind,
|
||||
PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics
|
||||
)
|
||||
}) {
|
||||
let client = transport(200, response);
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
for limit in [0, 101] {
|
||||
let mut invalid = input.clone();
|
||||
invalid["limit"] = json!(limit);
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert!(requests[0].path.contains("limit=10"));
|
||||
match kind {
|
||||
PublicAssignmentKind::Assignments => assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.contains("after_assignment_id=old%2Fdelegation%3F")
|
||||
),
|
||||
PublicAssignmentKind::Diagnostics => {
|
||||
assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.contains("after_input_event_id=ticket-event%3A7")
|
||||
);
|
||||
assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.contains("after_start_operation_id=start%2F7")
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() {
|
||||
for (kind, input, _) in cases() {
|
||||
for response in [
|
||||
Ok(WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "permission denied".into(),
|
||||
}),
|
||||
Ok(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "stale assignment or operation fingerprint mismatch".into(),
|
||||
}),
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "invalid response after dispatch".into(),
|
||||
}),
|
||||
Err("response lost; outcome unknown".into()),
|
||||
] {
|
||||
let client = Arc::new(Transport {
|
||||
requests: Mutex::new(vec![]),
|
||||
available: true,
|
||||
response,
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
client.requests.lock().unwrap().len(),
|
||||
1,
|
||||
"{} retried",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
let client = Arc::new(Transport {
|
||||
requests: Mutex::new(vec![]),
|
||||
available: false,
|
||||
response: Err("unavailable".into()),
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backend Workspace identity")
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() {
|
||||
let (kind, input, mut response) = cases().remove(3);
|
||||
response["rendered_content"] = json!("must not reach model");
|
||||
response["authentication_binding_id"] = json!("must not reach model");
|
||||
response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]);
|
||||
response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]);
|
||||
let (_, tool) = definition(transport(200, response), kind)();
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert!(output.get("rendered_content").is_none());
|
||||
assert!(output.get("authentication_binding_id").is_none());
|
||||
assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true);
|
||||
assert!(output["inputs"][0].get("rendered_content").is_none());
|
||||
assert_eq!(output["starts"][0]["actual_processing_unknown"], true);
|
||||
assert!(
|
||||
output["starts"][0]
|
||||
.get("authentication_binding_id")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() {
|
||||
for access in [
|
||||
TicketFeatureAccess::read_only(),
|
||||
TicketFeatureAccess::review(),
|
||||
] {
|
||||
let client = transport(200, json!({}));
|
||||
let normal = enabled_ticket_definitions(client.clone(), access);
|
||||
let native = native_ticket_tools(client, access).unwrap();
|
||||
for name in READ_TOOL_NAMES {
|
||||
assert!(normal.iter().any(|d| d().0.name == *name));
|
||||
assert!(native.iter().any(|t| t.name == *name));
|
||||
}
|
||||
for name in [
|
||||
"TicketOrchestratorAssign",
|
||||
"TicketOrchestratorClear",
|
||||
"TicketAssignmentAnswer",
|
||||
] {
|
||||
assert!(!normal.iter().any(|d| d().0.name == name));
|
||||
assert!(!native.iter().any(|t| t.name == name));
|
||||
}
|
||||
}
|
||||
for name in TOOL_NAMES {
|
||||
let native = native_ticket_operation(name).unwrap();
|
||||
assert_eq!(native.identity_field, Some("ticket"));
|
||||
assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name));
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() {
|
||||
let client = transport(
|
||||
200,
|
||||
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
|
||||
);
|
||||
let (meta, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]});
|
||||
assert!(schema.is_valid(&input));
|
||||
let invalid = json!({"closure_reason":"bug"});
|
||||
assert!(!schema.is_valid(&invalid));
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["closure_reason"], "completed");
|
||||
assert_eq!(body["tag_ids"], json!(["tag-bug"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() {
|
||||
let client = transport(
|
||||
200,
|
||||
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
|
||||
);
|
||||
let (meta, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5});
|
||||
assert!(schema.is_valid(&input));
|
||||
for status in ["running", "idle", "stopped", "closed"] {
|
||||
let mut invalid = input.clone();
|
||||
invalid["handling_status"] = json!(status);
|
||||
assert!(!schema.is_valid(&invalid));
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["assigned_principal"], input["assigned_principal"]);
|
||||
assert_eq!(body["handling_status"], "waiting");
|
||||
assert_eq!(body["states"], json!(["open"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for profile in [
|
||||
"intake",
|
||||
"orchestrator",
|
||||
"coder",
|
||||
"ticket-worker",
|
||||
"reviewer",
|
||||
] {
|
||||
let resolved = manifest::ProfileResolver::new()
|
||||
.with_workspace_base(dir.path())
|
||||
.resolve(
|
||||
&manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")),
|
||||
manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"),
|
||||
)
|
||||
.unwrap();
|
||||
let config = resolved.manifest.feature.ticket;
|
||||
assert!(config.enabled, "{profile}");
|
||||
let delegates = config.authoring || config.workflow;
|
||||
let answers = delegates || config.thread;
|
||||
let reports = config.workflow;
|
||||
let access = TicketFeatureAccess {
|
||||
authoring: config.authoring,
|
||||
thread: config.thread,
|
||||
intake: config.intake,
|
||||
workflow: config.workflow,
|
||||
};
|
||||
let names = access.tool_names();
|
||||
assert_eq!(
|
||||
names.contains(&"TicketOrchestratorAssign"),
|
||||
delegates,
|
||||
"{profile}"
|
||||
);
|
||||
assert_eq!(
|
||||
names.contains(&"TicketOrchestratorClear"),
|
||||
delegates,
|
||||
"{profile}"
|
||||
);
|
||||
assert_eq!(
|
||||
names.contains(&"TicketAssignmentAnswer"),
|
||||
answers,
|
||||
"{profile}"
|
||||
);
|
||||
assert_eq!(
|
||||
names.contains(&"TicketAssignmentTrackingUpdate"),
|
||||
reports,
|
||||
"{profile}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"TicketAssignmentsQuery")
|
||||
&& names.contains(&"TicketAssignmentDiagnostics"),
|
||||
"{profile}"
|
||||
);
|
||||
assert!(
|
||||
!names.iter().any(|name| matches!(
|
||||
*name,
|
||||
"TicketQueue" | "TicketMarkReady" | "TicketIntakeReady"
|
||||
)),
|
||||
"{profile}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() {
|
||||
let (kind, input, response) = cases().pop().unwrap();
|
||||
let client = transport(200, response);
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
for body in [
|
||||
String::new(),
|
||||
" ".into(),
|
||||
"x".repeat(16385),
|
||||
"text\0hidden".into(),
|
||||
] {
|
||||
let mut invalid = input.clone();
|
||||
invalid["body"] = json!(body);
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for field in ["assignment_id", "expected_last_event_id", "operation_key"] {
|
||||
for value in [String::new(), "x\n".into(), "x".repeat(1025)] {
|
||||
let mut invalid = input.clone();
|
||||
invalid[field] = json!(value);
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() {
|
||||
for (kind, mut input, response) in cases() {
|
||||
let client = transport(200, response);
|
||||
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
|
||||
let handler = TicketItemWipHandler {
|
||||
tools: operation_map(
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
|
||||
.collect(),
|
||||
),
|
||||
permissions: None,
|
||||
ticket_reference: "T-743".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
input.as_object_mut().unwrap().remove("ticket");
|
||||
let args: BTreeMap<_, _> = input
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
|
||||
.collect();
|
||||
let operation = native_ticket_operation(kind.name()).unwrap().operation;
|
||||
handler
|
||||
.call(
|
||||
operation,
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("WIP {operation} rejected"));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
let mut forged = args.clone();
|
||||
forged.insert("ticket".into(), WipValue::String("T-other".into()));
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
operation,
|
||||
&forged,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
let (_, input, response) = cases().pop().unwrap();
|
||||
let client = transport(200, response);
|
||||
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
|
||||
let denied = TicketItemWipHandler {
|
||||
tools: operation_map(
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
|
||||
.collect(),
|
||||
),
|
||||
permissions: Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: vec![],
|
||||
}),
|
||||
ticket_reference: "T-743".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
let args = input
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(k, _)| k.as_str() != "ticket")
|
||||
.map(|(k, v)| (k.clone(), json_to_wip(v).unwrap()))
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
denied
|
||||
.call(
|
||||
"assignment_answer",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into()
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::PermissionDenied,
|
||||
..
|
||||
}))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Self-reported handling of one immutable delegation. This transport never
|
||||
//! supplies actor/role authority or converts tracking into execution lifecycle.
|
||||
//!
|
||||
//! Updates forward the complete handling payload with the observed event of that exact assignment,
|
||||
//! not a status-only comparison. A replayed receipt is not a fresh observation for the next update.
|
||||
//! Query/history may inspect retained assignments and unknown legacy handling; read access does not
|
||||
//! enable self-report mutation. Unknown handling is not inferred waiting or successful done.
|
||||
use super::*;
|
||||
|
||||
pub(super) const TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentTrackingUpdate",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum TrackingToolKind {
|
||||
Query,
|
||||
History,
|
||||
Update,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct QueryInput {
|
||||
/// Ticket reference, normally T-*.
|
||||
ticket: String,
|
||||
/// Omit for the current delegation; set to inspect a retained assignment and release.
|
||||
assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct HistoryInput {
|
||||
ticket: String,
|
||||
/// Immutable delegation identity, including released or replaced assignments.
|
||||
assignment_id: String,
|
||||
/// Continue after an event identity returned by this assignment's history.
|
||||
after_event_id: Option<String>,
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateInput {
|
||||
ticket: String,
|
||||
assignment_id: String,
|
||||
/// Last event observed for this exact delegation, not Ticket status or Worker state.
|
||||
expected_last_event_id: String,
|
||||
/// Reuse the same key and complete input after an uncertain result.
|
||||
operation_key: String,
|
||||
tracking: ticket::TicketAssignmentTracking,
|
||||
}
|
||||
|
||||
impl TrackingToolKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => TOOL_NAMES[0],
|
||||
Self::History => TOOL_NAMES[1],
|
||||
Self::Update => TOOL_NAMES[2],
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::Query => json!(schemars::schema_for!(QueryInput)),
|
||||
Self::History => json!(schemars::schema_for!(HistoryInput)),
|
||||
Self::Update => json!(schemars::schema_for!(UpdateInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketAssignmentTrackingQuery" => {
|
||||
"Read the current Ticket delegation, or one retained assignment_id with its role/principal, Assign actor/time and separate release/replacement record, and durable self-reported waiting/inprogress/done, last event identity, waiting reason, progress, next action and result. Tracking is independent of Ticket open/closed, Worker running/idle/stop, MR and delivery state."
|
||||
}
|
||||
"TicketAssignmentTrackingHistory" => {
|
||||
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
|
||||
}
|
||||
"TicketAssignmentTrackingUpdate" => {
|
||||
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
|
||||
}
|
||||
_ => unreachable!("unknown assignment tracking tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_segment(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
char::from(byte).to_string()
|
||||
} else {
|
||||
format!("%{byte:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrackingTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TrackingToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TrackingTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let workspace = self
|
||||
.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Assignment tracking requires Backend Workspace identity; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace));
|
||||
let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
|
||||
let (method, path, body) = match self.kind {
|
||||
TrackingToolKind::Query => {
|
||||
let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
let mut path = format!(
|
||||
"{prefix}/{}/assignment-tracking",
|
||||
encode_segment(&input.ticket)
|
||||
);
|
||||
if let Some(assignment_id) = input.assignment_id {
|
||||
required("assignment_id", &assignment_id)?;
|
||||
path.push_str(&format!(
|
||||
"?assignment_id={}",
|
||||
encode_segment(&assignment_id)
|
||||
));
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, path, None)
|
||||
}
|
||||
TrackingToolKind::History => {
|
||||
let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
required("assignment_id", &input.assignment_id)?;
|
||||
let limit = input.limit.unwrap_or(100);
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be between 1 and 100".into(),
|
||||
));
|
||||
}
|
||||
let mut path = format!(
|
||||
"{prefix}/{}/assignment-tracking/history?assignment_id={}&limit={limit}",
|
||||
encode_segment(&input.ticket),
|
||||
encode_segment(&input.assignment_id)
|
||||
);
|
||||
if let Some(after) = input.after_event_id {
|
||||
required("after_event_id", &after)?;
|
||||
path.push_str(&format!("&after_event_id={}", encode_segment(&after)));
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, path, None)
|
||||
}
|
||||
TrackingToolKind::Update => {
|
||||
let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
for (name, value) in [
|
||||
("ticket", &input.ticket),
|
||||
("assignment_id", &input.assignment_id),
|
||||
("expected_last_event_id", &input.expected_last_event_id),
|
||||
("operation_key", &input.operation_key),
|
||||
] {
|
||||
required(name, value)?;
|
||||
}
|
||||
// Unknown fields (including author, role, runtime and worker) are
|
||||
// rejected before transport; authenticated context belongs to Backend.
|
||||
let body = json!({"assignment_id": input.assignment_id, "expected_last_event_id": input.expected_last_event_id, "operation_key": input.operation_key, "tracking": input.tracking});
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"{prefix}/{}/assignment-tracking",
|
||||
encode_segment(&input.ticket)
|
||||
),
|
||||
Some(body.to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, path, body),
|
||||
None => WorkspaceRequest::get(path),
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
let detail: String = response.body.chars().take(1024).collect();
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Assignment tracking request failed with HTTP status {}: {}",
|
||||
response.status, detail
|
||||
)));
|
||||
}
|
||||
if response.body.len() > 1024 * 1024 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Assignment tracking response exceeds 1 MiB; request a smaller history page".into(),
|
||||
));
|
||||
}
|
||||
let content: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Backend returned invalid assignment tracking JSON: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TrackingToolKind,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(TrackingTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Transport {
|
||||
workspace: Option<String>,
|
||||
response: WorkspaceResponse,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
impl WorkspaceClient for Transport {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace.as_deref()
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"tracking-contract-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
fn transport(status: u16, body: &str) -> Arc<Transport> {
|
||||
Arc::new(Transport {
|
||||
workspace: Some("workspace".into()),
|
||||
response: WorkspaceResponse {
|
||||
status,
|
||||
body: body.into(),
|
||||
},
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn update() -> Value {
|
||||
json!({"ticket":"T-741", "assignment_id":"delegation", "expected_last_event_id":"assigned-event", "operation_key":"report", "tracking":{"status":"done", "waiting_reason":null, "progress":"Alternatives compared", "next_action":"Human decision", "result":"Returned options"}})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_update_requires_observed_identity_and_excludes_actor_claims() {
|
||||
let client = transport(
|
||||
200,
|
||||
r#"{"assignment_id":"delegation","last_event_id":"reported-event","tracking":{"status":"done"}}"#,
|
||||
);
|
||||
let (meta, tool) = definition(client.clone(), TrackingToolKind::Update)();
|
||||
assert_eq!(meta.name, "TicketAssignmentTrackingUpdate");
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&update()));
|
||||
for field in [
|
||||
"assignment_id",
|
||||
"expected_last_event_id",
|
||||
"operation_key",
|
||||
"tracking",
|
||||
] {
|
||||
let mut input = update();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
assert!(!validator.is_valid(&input), "required {field}");
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"author",
|
||||
"role",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"source_context",
|
||||
] {
|
||||
let mut input = update();
|
||||
input[field] = json!("orchestrator");
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err(),
|
||||
"reject {field}"
|
||||
);
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
for _ in 0..2 {
|
||||
let output = tool
|
||||
.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&output.content.unwrap()).unwrap()["last_event_id"],
|
||||
"reported-event"
|
||||
);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests[0], requests[1],
|
||||
"uncertain retry preserves full input"
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/workspaces/workspace/tickets/T-741/assignment-tracking"
|
||||
);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["tracking"]["status"], "done");
|
||||
assert!(body.get("ticket").is_none());
|
||||
assert!(body.get("author").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_reads_are_bounded_and_carry_history_cursor() {
|
||||
let client = transport(200, r#"{"items":[],"next_event_id":null}"#);
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::History)();
|
||||
for limit in [0, 101] {
|
||||
assert!(
|
||||
tool.execute(
|
||||
&json!({"ticket":"T-741", "assignment_id":"a", "limit":limit}).to_string(),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
tool.execute(
|
||||
&json!({"ticket":"T-741", "assignment_id":"a", "after_event_id":"event/1", "limit":10})
|
||||
.to_string(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert!(
|
||||
requests[0]
|
||||
.path
|
||||
.ends_with("?assignment_id=a&limit=10&after_event_id=event%2F1")
|
||||
);
|
||||
drop(requests);
|
||||
let oversized = transport(200, &"x".repeat(1024 * 1024 + 1));
|
||||
let (_, tool) = definition(oversized, TrackingToolKind::Query)();
|
||||
assert!(
|
||||
tool.execute(r#"{"ticket":"T-741"}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("1 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_assignment_query_encodes_identity_without_changing_workspace_scope() {
|
||||
let client = transport(
|
||||
200,
|
||||
r#"[{"assignment_id":"old/delegation?x","current":false,"release":{"action":"replaced"}}]"#,
|
||||
);
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::Query)();
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"ticket":"T-741","assignment_id":"old/delegation?x"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let content: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(content[0]["current"], false);
|
||||
assert_eq!(content[0]["release"]["action"], "replaced");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/workspaces/workspace/tickets/T-741/assignment-tracking?assignment_id=old%2Fdelegation%3Fx"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_requires_backend_and_propagates_authority_rejection() {
|
||||
let client = Arc::new(Transport {
|
||||
workspace: None,
|
||||
response: WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
},
|
||||
requests: Mutex::new(Vec::new()),
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::Update)();
|
||||
assert!(
|
||||
tool.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backend Workspace identity")
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let client = transport(403, r#"{"code":"current_orchestrator_required"}"#);
|
||||
let (_, tool) = definition(client, TrackingToolKind::Update)();
|
||||
assert!(
|
||||
tool.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("403")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_tracking_tools_share_native_wip_and_access_contract() {
|
||||
let client = transport(200, "{}");
|
||||
let definitions = enabled_ticket_definitions(client, TicketFeatureAccess::workflow());
|
||||
for name in TOOL_NAMES {
|
||||
let (meta, _) = definitions
|
||||
.iter()
|
||||
.find(|definition| definition().0.name == *name)
|
||||
.unwrap()();
|
||||
let native = native_ticket_operation(name).unwrap();
|
||||
assert_eq!(native.identity_field, Some("ticket"));
|
||||
assert_eq!(native.mutating, *name == "TicketAssignmentTrackingUpdate");
|
||||
assert!(
|
||||
meta.description.contains("delegation")
|
||||
|| meta.description.contains("assignment_id")
|
||||
);
|
||||
}
|
||||
let reads = TicketFeatureAccess::read_only().tool_names();
|
||||
assert!(reads.contains(&"TicketAssignmentTrackingQuery"));
|
||||
assert!(reads.contains(&"TicketAssignmentTrackingHistory"));
|
||||
assert!(!reads.contains(&"TicketAssignmentTrackingUpdate"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Classification tools share the Ticket feature's scoped Backend transport.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
use super::*;
|
||||
|
||||
pub(super) const TAG_TOOL_NAMES: &[&str] = &[
|
||||
"WorkspaceTagList",
|
||||
"WorkspaceTagCreate",
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum TagToolKind {
|
||||
List,
|
||||
Create,
|
||||
Rename,
|
||||
Delete,
|
||||
Set,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListInput {
|
||||
/// Continue strictly after this stable tag ID. Continue until an empty page.
|
||||
after: Option<String>,
|
||||
/// Maximum results, 0..=200; defaults to 200.
|
||||
#[schemars(range(max = 200))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateInput {
|
||||
/// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes.
|
||||
name: String,
|
||||
/// Retry an uncertain request with this same key and exact input.
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RenameInput {
|
||||
tag_id: String,
|
||||
/// Last operation key observed on this exact identity; rename-back is still a change.
|
||||
expected_last_operation_key: String,
|
||||
name: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DeleteInput {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SetInput {
|
||||
/// Ticket reference, preferably T-*; WIP binds it from the item route.
|
||||
ticket: String,
|
||||
/// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed.
|
||||
#[schemars(length(max = 100))]
|
||||
expected_tag_ids: Vec<String>,
|
||||
/// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all.
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids: Vec<String>,
|
||||
operation_key: String,
|
||||
}
|
||||
|
||||
impl TagToolKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::List => "WorkspaceTagList",
|
||||
Self::Create => "WorkspaceTagCreate",
|
||||
Self::Rename => "WorkspaceTagRename",
|
||||
Self::Delete => "WorkspaceTagDelete",
|
||||
Self::Set => "TicketTagsSet",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::List => json!(schemars::schema_for!(ListInput)),
|
||||
Self::Create => json!(schemars::schema_for!(CreateInput)),
|
||||
Self::Rename => json!(schemars::schema_for!(RenameInput)),
|
||||
Self::Delete => json!(schemars::schema_for!(DeleteInput)),
|
||||
Self::Set => json!(schemars::schema_for!(SetInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"WorkspaceTagList" => {
|
||||
"List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page."
|
||||
}
|
||||
"WorkspaceTagCreate" => {
|
||||
"Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants."
|
||||
}
|
||||
"WorkspaceTagRename" => {
|
||||
"Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits."
|
||||
}
|
||||
"WorkspaceTagDelete" => {
|
||||
"Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity."
|
||||
}
|
||||
"TicketTagsSet" => {
|
||||
"Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits."
|
||||
}
|
||||
_ => unreachable!("unknown classification tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_name(name: &str) -> Result<(), ToolError> {
|
||||
if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> {
|
||||
if ids.len() > 100 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain at most 100 IDs"
|
||||
)));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for id in ids {
|
||||
required(name, id)?;
|
||||
if !unique.insert(id) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain unique IDs"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TagTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TagToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TagTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Classification tools require Backend Workspace identity; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let backend = WorkspaceHttpTicketBackend::new(self.client.clone());
|
||||
let content = match self.kind {
|
||||
TagToolKind::List => {
|
||||
let input: ListInput = parse(input)?;
|
||||
if input.limit.is_some_and(|limit| limit > 200) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be at most 200".into(),
|
||||
));
|
||||
}
|
||||
if let Some(after) = &input.after {
|
||||
required("after", after)?;
|
||||
}
|
||||
let limit = input.limit.unwrap_or(200);
|
||||
let mut tags = backend
|
||||
.list_workspace_tags(ticket::TagListRequest {
|
||||
after: input.after,
|
||||
limit: Some(limit),
|
||||
})
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
tags.truncate(limit);
|
||||
json!(tags)
|
||||
}
|
||||
TagToolKind::Set => {
|
||||
let input: SetInput = parse(input)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
required("operation_key", &input.operation_key)?;
|
||||
validate_ids("expected_tag_ids", &input.expected_tag_ids)?;
|
||||
validate_ids("tag_ids", &input.tag_ids)?;
|
||||
let tags = backend
|
||||
.set_ticket_tags(
|
||||
TicketIdOrSlug::from(input.ticket.as_str()),
|
||||
ticket::TicketTagSetRequest {
|
||||
expected_tag_ids: input.expected_tag_ids,
|
||||
tag_ids: input.tag_ids,
|
||||
operation_key: input.operation_key,
|
||||
},
|
||||
)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if tags.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Backend returned more than 100 Ticket tags".into(),
|
||||
));
|
||||
}
|
||||
json!(tags)
|
||||
}
|
||||
kind => {
|
||||
let request = match kind {
|
||||
TagToolKind::Create => {
|
||||
let input: CreateInput = parse(input)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Create { name: input.name },
|
||||
}
|
||||
}
|
||||
TagToolKind::Rename => {
|
||||
let input: RenameInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
name: input.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
TagToolKind::Delete => {
|
||||
let input: DeleteInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Delete {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
required("operation_key", &request.operation_key)?;
|
||||
json!(
|
||||
backend
|
||||
.mutate_workspace_tag(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: TagToolKind) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(TagTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
body: String,
|
||||
identity: bool,
|
||||
status: u16,
|
||||
}
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.identity.then_some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"classification-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: self.status,
|
||||
body: self.body.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn recording(body: Value) -> Arc<RecordingClient> {
|
||||
Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: body.to_string(),
|
||||
identity: true,
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
fn tag() -> Value {
|
||||
json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false})
|
||||
}
|
||||
fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
|
||||
vec![
|
||||
(TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"),
|
||||
(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"Ready", "operation_key":"create-1"}),
|
||||
"WorkspaceTagCreate",
|
||||
),
|
||||
(
|
||||
TagToolKind::Rename,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}),
|
||||
"WorkspaceTagRename",
|
||||
),
|
||||
(
|
||||
TagToolKind::Delete,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}),
|
||||
"WorkspaceTagDelete",
|
||||
),
|
||||
(
|
||||
TagToolKind::Set,
|
||||
json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}),
|
||||
"TicketTagsSet",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
|
||||
for (kind, input, name) in cases() {
|
||||
let client = recording(json!([]));
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
assert_eq!(meta.name, name);
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema);
|
||||
for field in meta.input_schema["required"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let field = field.as_str().unwrap();
|
||||
let mut invalid = input.clone();
|
||||
invalid.as_object_mut().unwrap().remove(field);
|
||||
assert!(!validator.is_valid(&invalid), "{name}.{field}");
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let mut invalid = input.clone();
|
||||
invalid["author"] = json!("orchestrator");
|
||||
assert!(!validator.is_valid(&invalid));
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
let schema = TagToolKind::Set.schema();
|
||||
assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(
|
||||
TagToolKind::List.schema()["properties"]["limit"]["maximum"],
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
|
||||
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
|
||||
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
|
||||
let (meta, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
assert_eq!(meta.input_schema["properties"][field]["maxItems"], 100);
|
||||
}
|
||||
let input = json!({"tag_ids":["tag-1"], "tag_ids_any":["tag-2", "tag-3"], "tag_ids_none":["tag-4"], "states":["open"], "limit":7});
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["tickets"][0]["tags"], json!([tag()]));
|
||||
assert_eq!(value["tickets"][0]["state"], "open");
|
||||
for field in ["closure_operation_key", "current_closure", "legacy_handoff"] {
|
||||
assert!(
|
||||
value["tickets"][0].as_object().unwrap().contains_key(field),
|
||||
"{field}"
|
||||
);
|
||||
assert_eq!(value["tickets"][0][field], Value::Null);
|
||||
}
|
||||
{
|
||||
let requests = client.requests.lock().unwrap();
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
for field in ["states", "tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
assert_eq!(body[field], input[field]);
|
||||
}
|
||||
}
|
||||
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
|
||||
let client = recording(detail);
|
||||
let (_, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||
let output = tool
|
||||
.execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["tags"], json!([tag()]));
|
||||
assert_eq!(value["content_digest"], "content-1");
|
||||
assert_eq!(value["state"], "open");
|
||||
assert_eq!(value["closure_history"], json!([]));
|
||||
for field in ["closure_operation_key", "current_closure", "legacy_handoff"] {
|
||||
assert!(value.as_object().unwrap().contains_key(field), "{field}");
|
||||
assert_eq!(value[field], Value::Null);
|
||||
}
|
||||
let client = recording(json!({}));
|
||||
let (_, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
let input = json!({field: (0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()});
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for state in [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"invalid",
|
||||
] {
|
||||
let input = json!({"states":[state], "tag_ids":["tag-1"]});
|
||||
assert!(
|
||||
matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
),
|
||||
"{state}"
|
||||
);
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() {
|
||||
let client = recording(json!([]));
|
||||
for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] {
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Create)();
|
||||
assert!(matches!(
|
||||
tool.execute(
|
||||
&json!({"name":name, "operation_key":"create"}).to_string(),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for ids in [
|
||||
json!(["tag-1", "tag-1"]),
|
||||
json!([""]),
|
||||
json!((0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()),
|
||||
] {
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
for field in ["tag_ids", "expected_tag_ids"] {
|
||||
let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"});
|
||||
input[field] = ids.clone();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
for (kind, mut input, _) in cases()
|
||||
.into_iter()
|
||||
.filter(|(kind, _, _)| !matches!(kind, TagToolKind::List))
|
||||
{
|
||||
input["operation_key"] = json!(" ");
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
assert!(
|
||||
tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_require_backend_workspace_and_bound_list_results() {
|
||||
let missing = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "[]".into(),
|
||||
identity: false,
|
||||
status: 200,
|
||||
});
|
||||
for (kind, input, _) in cases() {
|
||||
let (_, tool) = definition(missing.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
assert!(missing.requests.lock().unwrap().is_empty());
|
||||
let client = recording(json!(vec![tag(); 201]));
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
for (input, expected) in [
|
||||
(json!({}), 200),
|
||||
(json!({"limit":2, "after":"a&b"}), 2),
|
||||
(json!({"limit":0}), 0),
|
||||
] {
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value.as_array().unwrap().len(), expected);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests[1].path,
|
||||
"/api/w/workspace/ticket-tags?limit=2&after=a%26b"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
|
||||
let client = recording(json!([tag()]));
|
||||
let tools =
|
||||
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
|
||||
let set = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "TicketTagsSet")
|
||||
.unwrap();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let permissions = Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "TicketTagsSet".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
pattern: input.to_string(),
|
||||
}],
|
||||
});
|
||||
let error = execute_native_ticket_tool(
|
||||
set,
|
||||
&permissions,
|
||||
input,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::PermissionDenied,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let handler = TicketItemWipHandler {
|
||||
tools: operation_map(
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||
.collect(),
|
||||
),
|
||||
permissions: None,
|
||||
ticket_reference: "T-740".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.insert("T-740".into(), "content-1".into());
|
||||
let mut args = BTreeMap::from([
|
||||
("operation_key".into(), WipValue::String("set-1".into())),
|
||||
("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()),
|
||||
("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()),
|
||||
]);
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
client.requests.lock().unwrap()[0].path,
|
||||
"/api/w/workspace/tickets/T-740/tags"
|
||||
);
|
||||
assert_eq!(
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.get("T-740")
|
||||
.map(String::as_str),
|
||||
Some("content-1")
|
||||
);
|
||||
args.insert("ticket".into(), WipValue::String("T-foreign".into()));
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
let read_only = TicketFeatureAccess::read_only().tool_names();
|
||||
assert!(read_only.contains(&"WorkspaceTagList"));
|
||||
assert!(!read_only.contains(&"TicketTagsSet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_preserve_backend_permission_and_stale_set_failures() {
|
||||
for status in [403, 409] {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "{}".into(),
|
||||
identity: true,
|
||||
status,
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let error = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(&status.to_string()));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() {
|
||||
use std::io::{Read, Write};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap();
|
||||
let reference = backend.create(NewTicket::new("Classify")).unwrap();
|
||||
let initial_content_digest =
|
||||
ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap());
|
||||
let ticket_id = reference.id.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server_db = db.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
for _ in 0..10 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let (headers, body) = loop {
|
||||
let mut chunk = [0u8; 8192];
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(bytes[..end].to_vec()).unwrap();
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
break (headers, bytes[end + 4..end + 4 + length].to_vec());
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
|
||||
let line = headers.lines().next().unwrap();
|
||||
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
|
||||
backend
|
||||
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
|
||||
.map(|tag| json!(tag))
|
||||
} else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) {
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
ticket_id.clone().into(),
|
||||
serde_json::from_slice(&body).unwrap(),
|
||||
)
|
||||
.map(|tags| json!(tags))
|
||||
} else {
|
||||
assert!(
|
||||
line.starts_with("GET /api/w/workspace/ticket-tags?"),
|
||||
"{line}"
|
||||
);
|
||||
backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.map(|tags| json!(tags))
|
||||
};
|
||||
let (status, response) = match result {
|
||||
Ok(value) => ("200 OK", value.to_string()),
|
||||
Err(error) => (
|
||||
"409 Conflict",
|
||||
json!({"message":error.to_string()}).to_string(),
|
||||
),
|
||||
};
|
||||
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
|
||||
}
|
||||
});
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
base,
|
||||
));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let call = |kind, input: Value| {
|
||||
runtime.block_on(async {
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
})
|
||||
};
|
||||
let first: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"ready", "operation_key":"create-a"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let second: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"closed", "operation_key":"create-b"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"});
|
||||
let receipt = call(TagToolKind::Set, set.clone()).unwrap();
|
||||
assert_eq!(
|
||||
call(TagToolKind::Set, set).unwrap().content,
|
||||
receipt.content
|
||||
);
|
||||
assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err());
|
||||
let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap();
|
||||
assert_eq!(removed.content.as_deref(), Some("[]"));
|
||||
assert!(
|
||||
backend
|
||||
.show(reference.id.clone().into())
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap();
|
||||
call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap();
|
||||
call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap();
|
||||
let listed = call(TagToolKind::List, json!({})).unwrap();
|
||||
let tags: Vec<ticket::WorkspaceTag> =
|
||||
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].name, "renamed");
|
||||
server.join().unwrap();
|
||||
let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap();
|
||||
let ticket = reopened.show(reference.id.into()).unwrap();
|
||||
assert_eq!(ticket.tags, tags);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&ticket),
|
||||
initial_content_digest
|
||||
);
|
||||
assert_eq!(ticket.meta.workflow_state, TicketState::Open);
|
||||
assert!(ticket.current_closure.is_none());
|
||||
assert!(ticket.closure_operation_key.is_none());
|
||||
assert!(ticket.case_history.is_empty());
|
||||
assert_eq!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.count(),
|
||||
4,
|
||||
"replay and stale input must not append membership events"
|
||||
);
|
||||
}
|
||||
@@ -1090,12 +1090,22 @@ impl Tool for McpStdioTool {
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.execute_with_limits(input_json, McpStdioLimits::default())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl McpStdioTool {
|
||||
async fn execute_with_limits(
|
||||
&self,
|
||||
input_json: &str,
|
||||
limits: McpStdioLimits,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let arguments = parse_tool_arguments(input_json)?;
|
||||
let mut client =
|
||||
McpStdioClient::connect(self.server_spec.clone(), McpStdioLimits::default())
|
||||
.await
|
||||
.map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?;
|
||||
let mut client = McpStdioClient::connect(self.server_spec.clone(), limits)
|
||||
.await
|
||||
.map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?;
|
||||
|
||||
let call_result = client
|
||||
.call_tool(CallToolRequest::new(self.mcp_tool_name.clone(), arguments))
|
||||
@@ -2217,6 +2227,64 @@ read exit_notification || true
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stdio_tool_request_timeout_reports_unknown_execution_without_replay() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("frames.jsonl");
|
||||
// Frame capture is observed only after shutdown has waited for process
|
||||
// exit. No guessed sleep or process-global test-control variable.
|
||||
let script = r#"
|
||||
printf '%s\n' 'connection' >> "$1"
|
||||
read -r init || exit 1
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"timeout-fixture","version":"1"}}}'
|
||||
read -r initialized || exit 1
|
||||
while IFS= read -r frame; do
|
||||
printf '%s\n' "$frame" >> "$1"
|
||||
done
|
||||
"#;
|
||||
let tool = McpStdioTool {
|
||||
server_spec: McpStdioServerSpec::new("timeout-fixture", "/bin/sh").args([
|
||||
"-c".to_string(),
|
||||
script.to_string(),
|
||||
"fixture".to_string(),
|
||||
log.to_str().unwrap().to_string(),
|
||||
]),
|
||||
mcp_tool_name: "demo-tool".to_string(),
|
||||
};
|
||||
let limits = McpStdioLimits {
|
||||
request_timeout: std::time::Duration::from_millis(100),
|
||||
shutdown_timeout: std::time::Duration::from_millis(100),
|
||||
kill_timeout: std::time::Duration::from_millis(100),
|
||||
..Default::default()
|
||||
};
|
||||
let err = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
tool.execute_with_limits("{}", limits),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("response intentionally withheld");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("tools/call transport/protocol failure"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("timed out during tools/call"), "{message}");
|
||||
assert!(
|
||||
message.contains("frame flushed; execution unknown; do not retry"),
|
||||
"{message}"
|
||||
);
|
||||
let capture = std::fs::read_to_string(log).unwrap();
|
||||
let mut lines = capture.lines();
|
||||
assert_eq!(lines.next(), Some("connection"));
|
||||
let frames: Vec<Value> = lines
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(frames.len(), 1, "must not reconnect or replay: {capture}");
|
||||
assert_eq!(frames[0]["method"], "tools/call");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_tool_result_renderer_marks_untrusted_mcp_error() {
|
||||
let result = CallToolResult {
|
||||
|
||||
@@ -68,6 +68,7 @@ pub(crate) struct InternalWorkerSpec {
|
||||
) + Send,
|
||||
>,
|
||||
>,
|
||||
pub compaction_budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
|
||||
pub features: FeatureRegistryBuilder,
|
||||
pub required_tools: &'static [&'static str],
|
||||
pub authority: InternalWorkerAuthority,
|
||||
@@ -118,6 +119,7 @@ where
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
compaction_budget,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -171,6 +173,7 @@ where
|
||||
identity: identity.clone(),
|
||||
history_entries: 0,
|
||||
})?;
|
||||
worker.install_compaction_budget(compaction_budget);
|
||||
worker.disable_manifest_lifecycle_features();
|
||||
if let Some(session) = inherited_workdir_session {
|
||||
worker.bind_single_workdir_session(Some(session));
|
||||
@@ -666,6 +669,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
compaction_budget,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -701,6 +705,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
||||
.map_err(|source| InternalWorkerSessionError::Build {
|
||||
message: source.to_string(),
|
||||
})?;
|
||||
worker.install_compaction_budget(compaction_budget);
|
||||
worker.disable_manifest_lifecycle_features();
|
||||
if let Some(session) = inherited_workdir_session {
|
||||
worker.bind_single_workdir_session(Some(session));
|
||||
@@ -762,6 +767,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
||||
cache_key,
|
||||
max_turns,
|
||||
engine_configurator,
|
||||
compaction_budget,
|
||||
features,
|
||||
required_tools,
|
||||
authority,
|
||||
@@ -794,6 +800,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
||||
.map_err(|source| InternalWorkerSessionError::Build {
|
||||
message: source.to_string(),
|
||||
})?;
|
||||
worker.install_compaction_budget(compaction_budget);
|
||||
worker.disable_manifest_lifecycle_features();
|
||||
if let Some(session) = inherited_workdir_session {
|
||||
worker.bind_single_workdir_session(Some(session));
|
||||
@@ -1442,6 +1449,7 @@ permission = "write"
|
||||
input: "input".to_string(),
|
||||
cache_key: Some("internal-test".to_string()),
|
||||
max_turns: Some(1),
|
||||
compaction_budget: None,
|
||||
engine_configurator: None,
|
||||
features: FeatureRegistryBuilder::new(),
|
||||
required_tools,
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn call_hook_before_deadline<E: HookEventKind>(
|
||||
}
|
||||
|
||||
pub(crate) struct WorkerInterceptor {
|
||||
compaction_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
|
||||
registry: Arc<HookRegistry>,
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
/// Shared view of the cumulative UsageRecord timeline. Used with the
|
||||
@@ -157,6 +158,7 @@ impl WorkerInterceptor {
|
||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
compaction_budget: None,
|
||||
registry,
|
||||
compact_state,
|
||||
usage_history,
|
||||
@@ -175,6 +177,14 @@ impl WorkerInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_compaction_budget(
|
||||
mut self,
|
||||
budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
|
||||
) -> Self {
|
||||
self.compaction_budget = budget;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_notification_committer(
|
||||
mut self,
|
||||
committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||
@@ -552,6 +562,8 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
&self,
|
||||
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
let context_invocation = context.invocation;
|
||||
let context_history = context.history;
|
||||
let context = context.items;
|
||||
let initial_tokens = self.estimated_tokens(context);
|
||||
if let Some(action) =
|
||||
@@ -616,7 +628,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
.expect("pending hook system-item queue poisoned"),
|
||||
);
|
||||
self.attach_prompt_provenance(&mut system_items);
|
||||
let appended_items: Vec<Item> = system_items
|
||||
let mut appended_items: Vec<Item> = system_items
|
||||
.iter()
|
||||
.map(SystemItem::to_history_item)
|
||||
.collect();
|
||||
@@ -652,10 +664,28 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
||||
usage_tracker.note_request(effective_context.len());
|
||||
// Internal compaction safety must survive the normal Worker interceptor
|
||||
// installation. Run it on the complete request after durable hook additions;
|
||||
// Engine persists any returned warning through its normal append path.
|
||||
if let Some(budget) = self.compaction_budget.as_ref() {
|
||||
let mut budget_context = effective_context.into_owned();
|
||||
match budget
|
||||
.pre_llm_request(PreLlmRequestContext {
|
||||
invocation: context_invocation,
|
||||
items: &mut budget_context,
|
||||
history: context_history,
|
||||
})
|
||||
.await?
|
||||
{
|
||||
PreRequestAction::Continue => {}
|
||||
PreRequestAction::ContinueWith(warnings) => appended_items.extend(warnings),
|
||||
action => return Ok(action),
|
||||
}
|
||||
}
|
||||
if system_items.is_empty() {
|
||||
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
||||
usage_tracker.note_request(context.len() + appended_items.len());
|
||||
}
|
||||
if appended_items.is_empty() {
|
||||
return Ok(PreRequestAction::Continue);
|
||||
}
|
||||
Ok(match self.commit_system_items(&system_items) {
|
||||
@@ -700,6 +730,12 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
if let Some(reason) = denials.into_iter().next() {
|
||||
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
|
||||
}
|
||||
if let Some(budget) = self.compaction_budget.as_ref() {
|
||||
match budget.pre_tool_call(info).await? {
|
||||
PreToolAction::Continue => {}
|
||||
action => return Ok(action),
|
||||
}
|
||||
}
|
||||
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ impl PreparedInternalJob {
|
||||
run_id: uuid::Uuid::now_v7(),
|
||||
},
|
||||
max_turns: engine_policy.max_turns.map(|n| n.get()),
|
||||
compaction_budget: None,
|
||||
engine_configurator: Some(Box::new(move |engine| {
|
||||
crate::apply_worker_manifest(engine, &engine_policy);
|
||||
})),
|
||||
|
||||
@@ -156,11 +156,15 @@ impl WorkerPrompt {
|
||||
];
|
||||
}
|
||||
|
||||
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
|
||||
/// Model-visible Ticket projection shared by Server and TUI attention paths.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct OrchestratorQueueAttentionTicket {
|
||||
resource_key: String,
|
||||
title: String,
|
||||
assignment_id: Option<String>,
|
||||
input_event_id: Option<String>,
|
||||
waiting_reason: Option<String>,
|
||||
next_action: Option<String>,
|
||||
}
|
||||
|
||||
impl OrchestratorQueueAttentionTicket {
|
||||
@@ -175,8 +179,28 @@ impl OrchestratorQueueAttentionTicket {
|
||||
Ok(Self {
|
||||
resource_key,
|
||||
title: bounded_queue_attention_text(&title.into(), 240),
|
||||
assignment_id: None,
|
||||
input_event_id: None,
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Committed delegation/input identity, not a handling-status transition.
|
||||
/// The receiving Worker must reread current authority before a new action.
|
||||
pub fn with_assignment_input(
|
||||
mut self,
|
||||
assignment_id: &str,
|
||||
input_event_id: &str,
|
||||
waiting_reason: Option<&str>,
|
||||
next_action: Option<&str>,
|
||||
) -> Self {
|
||||
self.assignment_id = Some(bounded_queue_attention_text(assignment_id, 1024));
|
||||
self.input_event_id = Some(bounded_queue_attention_text(input_event_id, 1024));
|
||||
self.waiting_reason = waiting_reason.map(|text| bounded_queue_attention_text(text, 1024));
|
||||
self.next_action = next_action.map(|text| bounded_queue_attention_text(text, 1024));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared model-visible context for every Orchestrator backlog attention renderer.
|
||||
@@ -200,7 +224,7 @@ impl OrchestratorQueueAttentionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt-catalog entries that must share the same backlog-attention body contract.
|
||||
/// Prompt-catalog entries sharing a bounded Ticket resource-key projection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrchestratorQueueAttentionPrompt {
|
||||
Server,
|
||||
@@ -796,7 +820,7 @@ mod tests {
|
||||
"ReviewMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
"TicketCaseUpdate",
|
||||
] {
|
||||
assert!(combined.contains(operation), "missing {operation}");
|
||||
}
|
||||
@@ -841,8 +865,11 @@ mod tests {
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(server, tui);
|
||||
assert!(server.starts_with("Queued Tickets require attention:"));
|
||||
assert!(server.starts_with("Assigned Tickets have unreceived input:"));
|
||||
assert!(tui.starts_with("Assigned Tickets have input requiring attention:"));
|
||||
assert!(tui.contains("no ready/Queue step is required"));
|
||||
assert!(tui.contains("were omitted from this notice: 1"));
|
||||
assert!(!tui.contains("T-21"));
|
||||
assert!(server.contains("- T-1 — Ticket 1 with control "));
|
||||
assert!(!server.contains("T-21"));
|
||||
assert!(server.contains("were omitted from this notice: 1"));
|
||||
@@ -859,6 +886,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_attention_carries_input_identity_and_bounded_waiting_context() {
|
||||
let ticket = OrchestratorQueueAttentionTicket::new("T-742", "Partial work")
|
||||
.unwrap()
|
||||
.with_assignment_input(
|
||||
"assignment-a",
|
||||
"answer-event",
|
||||
Some("human\nanswer"),
|
||||
Some(&"x".repeat(2048)),
|
||||
);
|
||||
let context = OrchestratorQueueAttentionContext::new(vec![ticket]);
|
||||
let output = PromptCatalog::builtins_only()
|
||||
.unwrap()
|
||||
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
|
||||
.unwrap();
|
||||
assert!(output.contains("Delegation: assignment-a; input: answer-event."));
|
||||
assert!(output.contains("Waiting reason: human answer"));
|
||||
assert!(output.contains(&format!("Next action: {}…", "x".repeat(1024))));
|
||||
assert!(!output.contains(&"x".repeat(1025)));
|
||||
assert!(output.contains("no ready/Queue step is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
|
||||
@@ -334,19 +334,28 @@ mod tests {
|
||||
fn ticket_workflow_fixture(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
ticket_fixture_with_access(
|
||||
role,
|
||||
mr_config,
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn ticket_fixture_with_access(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
access: TicketFeatureAccess,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(
|
||||
client.clone(),
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_module(TicketFeature::new(client.clone(), access))
|
||||
.with_module(MergeRequestFeature::new(client, mr_config))
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
@@ -384,12 +393,12 @@ mod tests {
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.find(|meta| meta.name == "TicketCaseUpdate")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("Merge Requests and approvals are not prerequisites")
|
||||
.contains("Repository, Merge Request and approval evidence are not prerequisites")
|
||||
);
|
||||
assert!(complete.description.contains("not real judgment"));
|
||||
assert!(
|
||||
@@ -461,12 +470,153 @@ mod tests {
|
||||
);
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.find(|meta| meta.name == "TicketCaseUpdate")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("return without conclusion need not call this tool")
|
||||
.contains("Return without conclusion need not call this tool")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() {
|
||||
for role in [
|
||||
"role.ticket_worker",
|
||||
"role.coder",
|
||||
"role.orchestrator",
|
||||
"role.intake",
|
||||
] {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
|
||||
for policy in [
|
||||
"only `open`",
|
||||
"stable tag IDs, not display names",
|
||||
"no ready step or separate Queue is required",
|
||||
"Orchestrator's self-reported handling",
|
||||
"Answers during waiting or ongoing handling continue the same assignment row",
|
||||
"explicit reissue creates a new row/assignment_id",
|
||||
"Respect an explicit user approval condition",
|
||||
"done does not require unassignment",
|
||||
"expected_last_event_id",
|
||||
"Exact retries retain the same key and full input",
|
||||
"Assign persistence, notification delivery, Runtime acceptance and actual work start",
|
||||
"concrete API insufficiency",
|
||||
] {
|
||||
assert!(prompt.contains(policy), "{role}: missing {policy}");
|
||||
}
|
||||
let update = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "TicketAssignmentTrackingUpdate")
|
||||
.unwrap();
|
||||
for policy in [
|
||||
"self-reported",
|
||||
"same row",
|
||||
"new Assign row",
|
||||
"complete tracking payload",
|
||||
"different input with that key is rejected",
|
||||
"reread current authority",
|
||||
"exact retry receipts are not fresh observations",
|
||||
] {
|
||||
assert!(
|
||||
update.description.contains(policy),
|
||||
"{role}: missing {policy}"
|
||||
);
|
||||
}
|
||||
assert!(!tools.iter().any(|meta| matches!(
|
||||
meta.name.as_str(),
|
||||
"TicketMarkReady"
|
||||
| "TicketQueue"
|
||||
| "TicketIntakeReady"
|
||||
| "CompleteTicket"
|
||||
| "TicketWorkflowState"
|
||||
| "TicketClose"
|
||||
)));
|
||||
if role == "role.orchestrator" {
|
||||
assert!(prompt.contains("observed current `orchestrator_assignment_id`"));
|
||||
assert!(prompt.contains(
|
||||
"generic `WorkerSpawn` Ticket starts and existing-Worker assignment"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scripted policy/input fixtures: they do not run a model or certify its judgment.
|
||||
#[test]
|
||||
fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let resolved = manifest::ProfileResolver::new()
|
||||
.with_workspace_base(dir.path())
|
||||
.resolve(
|
||||
&manifest::ProfileSelector::parse_cli("builtin:orchestrator"),
|
||||
manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"),
|
||||
)
|
||||
.unwrap();
|
||||
let config = resolved.manifest.feature.ticket;
|
||||
// Omit MR feature for the no-repository/no-MR work fixture; profile selection
|
||||
// makes it available, not mandatory to exercise for every Ticket.
|
||||
let (prompt, tools) = ticket_fixture_with_access(
|
||||
&resolved.manifest.engine.instruction,
|
||||
MergeRequestFeatureConfig::default(),
|
||||
TicketFeatureAccess {
|
||||
authoring: config.authoring,
|
||||
thread: config.thread,
|
||||
intake: config.intake,
|
||||
workflow: config.workflow,
|
||||
},
|
||||
);
|
||||
assert!(!tools.iter().any(|meta| matches!(
|
||||
meta.name.as_str(),
|
||||
"OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest"
|
||||
)));
|
||||
for (policy, name, input) in [
|
||||
(
|
||||
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
|
||||
"TicketOrchestratorAssign",
|
||||
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
|
||||
),
|
||||
(
|
||||
"research returned to a human can be `open` + handling `done`",
|
||||
"TicketAssignmentTrackingUpdate",
|
||||
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}),
|
||||
),
|
||||
(
|
||||
"Respect an explicit user approval condition",
|
||||
"TicketAssignmentTrackingUpdate",
|
||||
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}),
|
||||
),
|
||||
(
|
||||
"use `TicketAssignmentAnswer` with that `assignment_id`",
|
||||
"TicketAssignmentAnswer",
|
||||
serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}),
|
||||
),
|
||||
(
|
||||
"No Merge Request or approval is a prerequisite for a generic Ticket decision",
|
||||
"TicketCaseUpdate",
|
||||
serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}),
|
||||
),
|
||||
] {
|
||||
assert!(prompt.contains(policy), "missing {policy}");
|
||||
let meta = tools.iter().find(|meta| meta.name == name).unwrap();
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&input), "{name} rejected {input}");
|
||||
let required = meta.input_schema["required"].as_array().unwrap();
|
||||
assert!(!required.iter().any(|field| matches!(
|
||||
field.as_str(),
|
||||
Some(
|
||||
"repository_key"
|
||||
| "merge_request_id"
|
||||
| "approval_event_id"
|
||||
| "workdir_attachments"
|
||||
)
|
||||
)));
|
||||
let mut forged = input;
|
||||
forged["actor"] = serde_json::json!("workspace-orchestrator");
|
||||
assert!(
|
||||
!validator.is_valid(&forged),
|
||||
"model-supplied authority accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Exploration domain for immutable Session captures, with no Workspace/Memory mutation capability.
|
||||
//!
|
||||
//! A capture is an ordered projection of committed entries; exploration excludes reasoning and
|
||||
//! system prompts. Overview, search, bounded reads, and evidence conversion share `SessionEntryRef`.
|
||||
//! system prompts, including Host compaction summaries and other derived context. Full inherited
|
||||
//! summary input belongs to compaction, not public observation or Memory evidence. Overview,
|
||||
//! search, bounded reads, and evidence conversion share `SessionEntryRef`.
|
||||
//! References identify entries in the selected Session and cannot be reused for another subject.
|
||||
//! Appends preserve existing entry identities. Sparse real user/assistant anchors and intervening
|
||||
//! entry counts index long histories. Search with an empty filter also provides bounded range lists.
|
||||
@@ -734,7 +736,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
|
||||
}
|
||||
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
|
||||
SessionEntryProvenance::DerivedSummary
|
||||
}
|
||||
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||
}
|
||||
}
|
||||
@@ -887,6 +891,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_summary_remains_private_to_compaction_not_observation_or_memory_evidence() {
|
||||
let entries = [
|
||||
(
|
||||
Item::system_message("host-summary-secret"),
|
||||
WorkerHistoryProvenance::CompactionSummary,
|
||||
),
|
||||
(
|
||||
Item::system_message("other-derived-secret"),
|
||||
WorkerHistoryProvenance::DerivedSummary,
|
||||
),
|
||||
(
|
||||
Item::system_message("raw-system-secret"),
|
||||
WorkerHistoryProvenance::LegacyUnknown,
|
||||
),
|
||||
(
|
||||
Item::reasoning("reasoning-secret"),
|
||||
WorkerHistoryProvenance::LegacyUnknown,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(item, origin)| crate::session_history::history_entry(item, origin))
|
||||
.collect::<Vec<_>>();
|
||||
let refs = entries
|
||||
.iter()
|
||||
.map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id))
|
||||
.collect::<Vec<_>>();
|
||||
let capture = SessionCapture::from_history_entries("segment", entries);
|
||||
assert!(capture.overview().is_empty());
|
||||
assert!(
|
||||
capture
|
||||
.search(&SearchOptions {
|
||||
query: "secret".into(),
|
||||
..SearchOptions::default()
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
capture
|
||||
.read(ReadSelector::EntryRange([0, 3]), ReadOptions::default())
|
||||
.entries
|
||||
.is_empty()
|
||||
);
|
||||
for reference in refs {
|
||||
assert!(capture.evidence_for(reference.as_str()).is_none());
|
||||
assert!(
|
||||
capture
|
||||
.read(ReadSelector::Id(reference.as_str()), ReadOptions::default())
|
||||
.entries
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_contains_user_and_assistant_only() {
|
||||
let view = SessionCapture::new(
|
||||
|
||||
@@ -17,6 +17,8 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
||||
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
||||
|
||||
pub(crate) const COMPACTION_SUMMARY_HEADER: &str = "[Compacted context summary]\n\n";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
@@ -99,7 +101,10 @@ fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadat
|
||||
}
|
||||
|
||||
/// Rebuild typed Worker history directly from the append-only Session Log.
|
||||
/// Missing legacy metadata is never inferred from role or plaintext.
|
||||
/// Missing metadata is not inferred from role or arbitrary plaintext. The one adopted legacy
|
||||
/// Host seed is an opening `DerivedSummary` system item in an annotated compacted Segment,
|
||||
/// bearing the exact Host summary marker. It is interpreted as `CompactionSummary` without
|
||||
/// rewriting the durable record; later items and pasted markers never qualify.
|
||||
pub(crate) fn restore_history_entries(
|
||||
_session_id: SessionId,
|
||||
_segment_id: SegmentId,
|
||||
@@ -108,8 +113,32 @@ pub(crate) fn restore_history_entries(
|
||||
let mut history = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
history: seed,
|
||||
compacted_from,
|
||||
..
|
||||
} => {
|
||||
history = seed.iter().map(from_logged).collect();
|
||||
// Older Host compactions always put this DerivedSummary system item
|
||||
// first in the replacement seed. Only that trusted construction boundary
|
||||
// may interpret the marker; never infer origin from appended/pasted text.
|
||||
if compacted_from.is_some()
|
||||
&& let Some(first) = history.first_mut()
|
||||
&& first.annotation.origin == WorkerHistoryProvenance::DerivedSummary
|
||||
&& matches!(
|
||||
&first.item,
|
||||
Item::Message {
|
||||
role: agen::Role::System,
|
||||
..
|
||||
}
|
||||
)
|
||||
&& first
|
||||
.item
|
||||
.as_text()
|
||||
.is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_HEADER))
|
||||
{
|
||||
first.annotation.origin = WorkerHistoryProvenance::CompactionSummary;
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||
history.extend(input.iter().map(from_logged))
|
||||
@@ -144,6 +173,79 @@ mod tests {
|
||||
use protocol::Segment;
|
||||
use session_store::LogEntry;
|
||||
|
||||
#[test]
|
||||
fn restore_recognizes_only_legacy_host_compaction_seed_summary() {
|
||||
let summary = history_entry(
|
||||
Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")),
|
||||
WorkerHistoryProvenance::DerivedSummary,
|
||||
);
|
||||
for compacted in [false, true] {
|
||||
let seed = LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: SessionId::now_v7(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![to_logged_history_entry(&summary)],
|
||||
forked_from: None,
|
||||
compacted_from: compacted.then(|| session_store::SegmentOrigin {
|
||||
segment_id: SegmentId::now_v7(),
|
||||
at_turn_index: 0,
|
||||
}),
|
||||
};
|
||||
let appended = LogEntry::AnnotatedUserInput {
|
||||
ts: 1,
|
||||
segments: vec![],
|
||||
extensions: vec![],
|
||||
history: vec![to_logged_history_entry(&summary)],
|
||||
};
|
||||
let restored = restore_history_entries(
|
||||
SessionId::now_v7(),
|
||||
SegmentId::now_v7(),
|
||||
&[seed, appended],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored[0].annotation.origin,
|
||||
if compacted {
|
||||
WorkerHistoryProvenance::CompactionSummary
|
||||
} else {
|
||||
WorkerHistoryProvenance::DerivedSummary
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
restored[1].annotation.origin,
|
||||
WorkerHistoryProvenance::DerivedSummary
|
||||
);
|
||||
assert_eq!(restored[0].annotation.entry_id, summary.annotation.entry_id);
|
||||
}
|
||||
for item in [
|
||||
Item::user_message(format!("{COMPACTION_SUMMARY_HEADER}pasted summary")),
|
||||
Item::system_message("[Session TaskStore snapshot] tasks"),
|
||||
] {
|
||||
let seed = LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: SessionId::now_v7(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![to_logged_history_entry(&history_entry(
|
||||
item,
|
||||
WorkerHistoryProvenance::DerivedSummary,
|
||||
))],
|
||||
forked_from: None,
|
||||
compacted_from: Some(session_store::SegmentOrigin {
|
||||
segment_id: SegmentId::now_v7(),
|
||||
at_turn_index: 0,
|
||||
}),
|
||||
};
|
||||
let restored =
|
||||
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &[seed]).unwrap();
|
||||
assert_eq!(
|
||||
restored[0].annotation.origin,
|
||||
WorkerHistoryProvenance::DerivedSummary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||
let session_id = SessionId::now_v7();
|
||||
|
||||
+336
-20
@@ -732,6 +732,28 @@ pub struct WorkspaceWorkerDiscoveryRequest {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceServerOperation {
|
||||
TicketAssignmentSet {
|
||||
ticket_id: String,
|
||||
role: server_api::TicketAssignmentRole,
|
||||
request: server_api::SetTicketRoleAssignmentRequest,
|
||||
},
|
||||
TicketAssignmentClear {
|
||||
ticket_id: String,
|
||||
role: server_api::TicketAssignmentRole,
|
||||
query: server_api::ClearTicketRoleAssignmentQuery,
|
||||
},
|
||||
TicketAssignmentsPage {
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentsQuery,
|
||||
},
|
||||
TicketAssignmentDiagnostics {
|
||||
ticket_id: String,
|
||||
query: server_api::TicketAssignmentDiagnosticsQuery,
|
||||
},
|
||||
TicketAssignmentAnswer {
|
||||
ticket_id: String,
|
||||
request: server_api::TicketAssignmentAnswerRequest,
|
||||
},
|
||||
WorkerControlList,
|
||||
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
|
||||
WorkerControlInput {
|
||||
@@ -787,6 +809,45 @@ fn encode_workspace_path_segment(value: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
fn workspace_server_query_request<T: serde::Serialize>(
|
||||
method: WorkspaceRequestMethod,
|
||||
path: String,
|
||||
query: &T,
|
||||
) -> Result<WorkspaceRequest, WorkspaceClientError> {
|
||||
let query = serde_json::to_value(query)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
let fields = query.as_object().ok_or_else(|| {
|
||||
WorkspaceClientError::Request("Workspace query must serialize to an object".into())
|
||||
})?;
|
||||
let mut pairs = Vec::new();
|
||||
for (name, value) in fields {
|
||||
let value = match value {
|
||||
serde_json::Value::Null => continue,
|
||||
serde_json::Value::String(value) => value.clone(),
|
||||
serde_json::Value::Number(value) => value.to_string(),
|
||||
_ => {
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"Workspace query fields must be scalar".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
pairs.push(format!(
|
||||
"{}={}",
|
||||
encode_workspace_path_segment(name),
|
||||
encode_workspace_path_segment(&value)
|
||||
));
|
||||
}
|
||||
Ok(WorkspaceRequest {
|
||||
method,
|
||||
path: if pairs.is_empty() {
|
||||
path
|
||||
} else {
|
||||
format!("{path}?{}", pairs.join("&"))
|
||||
},
|
||||
body: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_server_operation_request(
|
||||
workspace_id: &str,
|
||||
operation: WorkspaceServerOperation,
|
||||
@@ -794,6 +855,76 @@ fn workspace_server_operation_request(
|
||||
let workspace_id = encode_workspace_path_segment(workspace_id);
|
||||
let base = format!("/api/w/{workspace_id}");
|
||||
match operation {
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id,
|
||||
role,
|
||||
request,
|
||||
} => {
|
||||
let role = match role {
|
||||
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
|
||||
server_api::TicketAssignmentRole::Worker => "worker",
|
||||
server_api::TicketAssignmentRole::Owner => "owner",
|
||||
server_api::TicketAssignmentRole::Contributor => "contributor",
|
||||
};
|
||||
Ok(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Put,
|
||||
format!(
|
||||
"{base}/tickets/{}/assignments/{role}",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
serde_json::to_string(&request)
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||
))
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentClear {
|
||||
ticket_id,
|
||||
role,
|
||||
query,
|
||||
} => {
|
||||
let role = match role {
|
||||
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
|
||||
server_api::TicketAssignmentRole::Worker => "worker",
|
||||
server_api::TicketAssignmentRole::Owner => "owner",
|
||||
server_api::TicketAssignmentRole::Contributor => "contributor",
|
||||
};
|
||||
workspace_server_query_request(
|
||||
WorkspaceRequestMethod::Delete,
|
||||
format!(
|
||||
"{base}/tickets/{}/assignments/{role}",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
&query,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
|
||||
workspace_server_query_request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!(
|
||||
"/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
&query,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
|
||||
workspace_server_query_request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!(
|
||||
"/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
&query,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
|
||||
workspace_server_json_request(
|
||||
format!(
|
||||
"/workspaces/{workspace_id}/tickets/{}/assignment-answer",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
&request,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
|
||||
"{base}/worker-control/workers"
|
||||
))),
|
||||
@@ -2804,6 +2935,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// manifest-derived lifecycle Features before controller startup.
|
||||
manifest_lifecycle_features_enabled: bool,
|
||||
interceptor_installed: bool,
|
||||
compaction_service_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
|
||||
/// Shared compaction state (present when threshold is configured).
|
||||
compact_state: Option<Arc<CompactState>>,
|
||||
/// Per-LLM-request Usage tracker. Always present after construction.
|
||||
@@ -3148,6 +3280,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
feature_invocations: FeatureInvocationRegistry::default(),
|
||||
manifest_lifecycle_features_enabled: true,
|
||||
interceptor_installed: false,
|
||||
compaction_service_budget: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
@@ -4188,6 +4321,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
self.callback_socket.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn install_compaction_budget(
|
||||
&mut self,
|
||||
budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
|
||||
) {
|
||||
self.assert_hooks_open();
|
||||
self.compaction_service_budget = budget.map(|budget| {
|
||||
Arc::new(
|
||||
budget.with_request_shape(
|
||||
self.engine()
|
||||
.get_system_prompt()
|
||||
.unwrap_or_default()
|
||||
.to_owned(),
|
||||
self.engine().tool_server_handle(),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hook registration ---
|
||||
|
||||
fn assert_hooks_open(&self) {
|
||||
@@ -4283,6 +4434,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
self.log_writer.clone(),
|
||||
self.pending_committed_history.clone(),
|
||||
)
|
||||
.with_compaction_budget(self.compaction_service_budget.clone())
|
||||
.with_notification_committer(self.notification_committer.clone())
|
||||
.with_usage_tracker(self.usage_tracker.clone())
|
||||
.with_metrics_tracker(self.metrics_tracker.clone())
|
||||
@@ -6603,7 +6755,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
|
||||
let history_entries = self.session.history().entries();
|
||||
let retain_from = cut.index.min(history_entries.len());
|
||||
let retained_history_entries = history_entries[retain_from..].to_vec();
|
||||
let initial_summary = initial_compaction_summary(history_entries)?;
|
||||
// The old summary is consumed in full by the compaction service even when
|
||||
// the cut falls before it. The replacement summary supersedes it: retaining
|
||||
// it as well would duplicate inherited context on every compact.
|
||||
let retained_history_entries = history_entries[retain_from..]
|
||||
.iter()
|
||||
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut retained_items = retained_history_entries
|
||||
.iter()
|
||||
.map(|entry| entry.item.clone())
|
||||
@@ -6616,7 +6776,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref());
|
||||
// The compactor explores summaries, text details, and ViewImage call arguments. It
|
||||
// never needs durable image bodies; relevant images are nominated with add_reference.
|
||||
let mut entries_to_summarise = history_entries[..retain_from].to_vec();
|
||||
let mut entries_to_summarise = history_entries[..retain_from]
|
||||
.iter()
|
||||
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
project_compactor_capture_attachments(&mut entries_to_summarise);
|
||||
let items_to_summarise = entries_to_summarise
|
||||
.iter()
|
||||
@@ -6681,11 +6845,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Input text fed to the compact worker. Includes the default
|
||||
// references, current TaskStore snapshot, current TaskStore snapshot, and the (pruned) conversation text.
|
||||
// The full Host summary is mandatory input, separate from the bounded
|
||||
// prefix overview and current TaskStore snapshot.
|
||||
let task_snapshot_text = self.task_feature.snapshot_text();
|
||||
let summary_input = build_summary_input(
|
||||
&items_to_summarise,
|
||||
initial_summary.as_deref(),
|
||||
&default_refs,
|
||||
Some(task_snapshot_text.as_str()),
|
||||
SummaryInputOptions {
|
||||
@@ -6797,12 +6962,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
input: summary_input.text.clone(),
|
||||
cache_key: Some(self.segment_id().to_string()),
|
||||
max_turns: worker_max_turns,
|
||||
compaction_budget: Some(interceptor),
|
||||
engine_configurator: Some(Box::new(move |engine| {
|
||||
let tracker = tracker_for_engine;
|
||||
engine.on_usage(move |event| {
|
||||
tracker.record_usage(event);
|
||||
});
|
||||
engine.set_interceptor(interceptor);
|
||||
})),
|
||||
features,
|
||||
required_tools,
|
||||
@@ -6862,7 +7027,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
Some(
|
||||
"You have not called `write_summary` yet. Deliver the structured \
|
||||
summary now (Completed Tasks / Active Task / Key Decisions / \
|
||||
User Directives / Current Work) and nominate any files the next \
|
||||
User Directives / Current Work), preserving ongoing goals and user \
|
||||
directives from the initial summary and segment. Nominate any files the next \
|
||||
session needs with `mark_read_required` for UTF-8 text or \
|
||||
`add_reference` for images, binary files, and on-demand reads."
|
||||
.to_string(),
|
||||
@@ -6894,11 +7060,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
.ok_or(WorkerError::CompactSummaryMissing)?;
|
||||
let mut summary_tokens = estimate_text_tokens(summary_text.len());
|
||||
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
|
||||
let prompt = format!(
|
||||
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
|
||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
||||
);
|
||||
let prompt =
|
||||
summary_shrink_prompt(summary_tokens, summary_max_tokens, summary_target_tokens);
|
||||
handle
|
||||
.send(prompt)
|
||||
.await
|
||||
@@ -7007,8 +7170,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
);
|
||||
let mut compact_introduced_system_messages =
|
||||
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
|
||||
let summary_message =
|
||||
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
|
||||
let summary_message = Item::system_message(format!(
|
||||
"{}{summary_text}",
|
||||
crate::session_history::COMPACTION_SUMMARY_HEADER
|
||||
));
|
||||
compact_introduced_system_messages.push(summary_message.clone());
|
||||
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
|
||||
if let Some(msg) = reference_message.as_ref() {
|
||||
@@ -7065,8 +7230,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let compacted_history_entries = new_history
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|item| {
|
||||
if let Some((offset, original)) = original_entries[original_cursor..]
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
if index == 0 {
|
||||
HistoryEntry::new(
|
||||
item,
|
||||
new_history_metadata(
|
||||
WorkerHistoryProvenance::CompactionSummary,
|
||||
Some(SessionHistoryDerivation {
|
||||
sources: derived_sources.clone(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else if let Some((offset, original)) = original_entries[original_cursor..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, original)| original.item == item)
|
||||
@@ -7482,6 +7658,7 @@ where
|
||||
feature_invocations: FeatureInvocationRegistry::default(),
|
||||
manifest_lifecycle_features_enabled: true,
|
||||
interceptor_installed: false,
|
||||
compaction_service_budget: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
@@ -7821,6 +7998,7 @@ where
|
||||
feature_invocations: FeatureInvocationRegistry::default(),
|
||||
manifest_lifecycle_features_enabled: false,
|
||||
interceptor_installed: false,
|
||||
compaction_service_budget: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
@@ -7950,6 +8128,7 @@ where
|
||||
feature_invocations: FeatureInvocationRegistry::default(),
|
||||
manifest_lifecycle_features_enabled: true,
|
||||
interceptor_installed: false,
|
||||
compaction_service_budget: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
@@ -8335,6 +8514,7 @@ where
|
||||
feature_invocations: FeatureInvocationRegistry::default(),
|
||||
manifest_lifecycle_features_enabled: true,
|
||||
interceptor_installed: false,
|
||||
compaction_service_budget: None,
|
||||
compact_state: None,
|
||||
usage_tracker: Arc::new(UsageTracker::new()),
|
||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||
@@ -8733,6 +8913,61 @@ fn build_related_file_message(
|
||||
)))
|
||||
}
|
||||
|
||||
fn summary_shrink_prompt(
|
||||
summary_tokens: u64,
|
||||
summary_max_tokens: u64,
|
||||
summary_target_tokens: u64,
|
||||
) -> String {
|
||||
format!(
|
||||
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
|
||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
||||
same five sections. Compress duplicate explanations and old work details \
|
||||
first; preserve ongoing goals and user directives from the initial summary \
|
||||
and segment, including scope, prohibitions, completion conditions and \
|
||||
exception-reporting duties. Apply explicit later changes/retractions and \
|
||||
retain uncertainty; do not replace the original request with recovery \
|
||||
procedures or recent progress. Target ≈{summary_target_tokens} tokens."
|
||||
)
|
||||
}
|
||||
|
||||
/// Select only the Host's typed summary, never a marker in ordinary content.
|
||||
/// Invalid/duplicate Host summaries are a broken seed contract, not permission
|
||||
/// to silently omit inherited instructions.
|
||||
fn initial_compaction_summary(
|
||||
entries: &[HistoryEntry<SessionHistoryMetadata>],
|
||||
) -> Result<Option<String>, WorkerError> {
|
||||
let mut summaries = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary);
|
||||
let Some(entry) = summaries.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if summaries.next().is_some() {
|
||||
return Err(WorkerError::InvalidState(
|
||||
"multiple Host compaction summaries in active history".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
&entry.item,
|
||||
Item::Message {
|
||||
role: agen::Role::System,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return Err(WorkerError::InvalidState(
|
||||
"Host compaction summary must be a system message".into(),
|
||||
));
|
||||
}
|
||||
let body = entry
|
||||
.item
|
||||
.as_text()
|
||||
.and_then(|text| text.strip_prefix(crate::session_history::COMPACTION_SUMMARY_HEADER))
|
||||
.ok_or_else(|| {
|
||||
WorkerError::InvalidState("Host compaction summary header is missing".into())
|
||||
})?;
|
||||
Ok(Some(body.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SummaryInputOptions {
|
||||
overview_target_tokens: u64,
|
||||
@@ -8749,11 +8984,12 @@ struct SummaryInputBuild {
|
||||
deadline_fallback_used: bool,
|
||||
}
|
||||
|
||||
/// Build the compact worker's input: default-reference instructions,
|
||||
/// the list of recently-touched files, task snapshot,
|
||||
/// and a bounded overview rather than a prefix-wide transcript.
|
||||
/// Build mandatory full Host-summary input plus a bounded prefix overview,
|
||||
/// recently-touched files and current tasks. Overview budgets never apply to
|
||||
/// the summary; the complete request is checked by the compaction interceptor.
|
||||
fn build_summary_input(
|
||||
items: &[Item],
|
||||
initial_summary: Option<&str>,
|
||||
default_refs: &[PathBuf],
|
||||
task_snapshot: Option<&str>,
|
||||
options: SummaryInputOptions,
|
||||
@@ -8779,10 +9015,20 @@ fn build_summary_input(
|
||||
out.push_str(&format!(
|
||||
"Summarise this session into a structured summary of about {} tokens and \
|
||||
nominate files the next session needs. The conversation below is a \
|
||||
bounded overview/index, not the full transcript. Use tools to inspect \
|
||||
bounded overview/index, not the full transcript. A separately supplied initial \
|
||||
compaction summary is complete, mandatory source material. Preserve ongoing \
|
||||
user goals and directives from it and from this segment's user messages; \
|
||||
confirm important wording with exploration rather than reconstructing the \
|
||||
request from recent progress. Retained tail entries remain verbatim in the \
|
||||
next session and are outside this prefix overview. Use tools to inspect \
|
||||
current files when deciding auto-read/reference output.\n\n",
|
||||
options.summary_target_tokens
|
||||
));
|
||||
if let Some(summary) = initial_summary {
|
||||
out.push_str("## Initial compaction summary (full text)\n");
|
||||
out.push_str(summary);
|
||||
out.push_str("\n\n## End initial compaction summary\n\n");
|
||||
}
|
||||
if !default_refs.is_empty() {
|
||||
out.push_str(
|
||||
"These files were touched recently in this session. Use `read_file` \
|
||||
@@ -8801,7 +9047,8 @@ fn build_summary_input(
|
||||
out.push_str(
|
||||
"## Current Session TaskStore\n\
|
||||
This is the full current task list. Use it as source material for the \
|
||||
summary, especially active (pending/inprogress) tasks, but do not edit tasks \
|
||||
summary, especially active (pending/inprogress) tasks, but not as a \
|
||||
replacement for user goals, directives or completion conditions. Do not edit tasks \
|
||||
from the compact worker.\n",
|
||||
);
|
||||
out.push_str(task_snapshot);
|
||||
@@ -10081,6 +10328,7 @@ mod build_summary_prompt_tests {
|
||||
fn test_summary_input(items: &[Item]) -> String {
|
||||
build_summary_input(
|
||||
items,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
SummaryInputOptions {
|
||||
@@ -10093,6 +10341,72 @@ mod build_summary_prompt_tests {
|
||||
.text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_prompt_and_shrink_policy_prioritize_continuing_user_intent() {
|
||||
// The builtin resource is the default model-visible compaction contract.
|
||||
let prompt = include_str!("../../../resources/prompts/internal/compact_system.md");
|
||||
for policy in [
|
||||
"initial compaction summary in full",
|
||||
"segment's user messages",
|
||||
"prohibitions",
|
||||
"completion conditions",
|
||||
"explicit changes or retractions",
|
||||
"Preserve uncertainty",
|
||||
"retained tail stays verbatim",
|
||||
"TaskStore",
|
||||
] {
|
||||
assert!(prompt.contains(policy), "missing prompt policy: {policy}");
|
||||
}
|
||||
let shrink = summary_shrink_prompt(2_000, 1_000, 500);
|
||||
for policy in [
|
||||
"initial summary",
|
||||
"and segment",
|
||||
"Compress duplicate",
|
||||
"prohibitions",
|
||||
"completion conditions",
|
||||
"exception-reporting duties",
|
||||
"changes/retractions",
|
||||
"uncertainty",
|
||||
"recovery",
|
||||
] {
|
||||
assert!(shrink.contains(policy), "missing shrink policy: {policy}");
|
||||
}
|
||||
let input = test_summary_input(&[Item::user_message("keep original scope")]);
|
||||
assert!(input.contains("Preserve ongoing user goals and directives"));
|
||||
assert!(input.contains("Retained tail entries remain verbatim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() {
|
||||
use crate::session_history::history_entry;
|
||||
let valid = history_entry(
|
||||
Item::system_message(format!(
|
||||
"{}body",
|
||||
crate::session_history::COMPACTION_SUMMARY_HEADER
|
||||
)),
|
||||
WorkerHistoryProvenance::CompactionSummary,
|
||||
);
|
||||
assert_eq!(
|
||||
initial_compaction_summary(std::slice::from_ref(&valid))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("body")
|
||||
);
|
||||
assert!(initial_compaction_summary(&[valid.clone(), valid]).is_err());
|
||||
for item in [
|
||||
Item::user_message("pasted"),
|
||||
Item::system_message("missing marker"),
|
||||
] {
|
||||
assert!(
|
||||
initial_compaction_summary(&[history_entry(
|
||||
item,
|
||||
WorkerHistoryProvenance::CompactionSummary
|
||||
)])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_images_become_on_demand_related_files() {
|
||||
let mut items = vec![
|
||||
@@ -10193,6 +10507,7 @@ mod build_summary_prompt_tests {
|
||||
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||
let built = build_summary_input(
|
||||
&items,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
SummaryInputOptions {
|
||||
@@ -10212,6 +10527,7 @@ mod build_summary_prompt_tests {
|
||||
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||
let built = build_summary_input(
|
||||
&items,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
SummaryInputOptions {
|
||||
|
||||
@@ -133,6 +133,7 @@ struct MockClient {
|
||||
responses: Arc<Vec<Vec<LlmEvent>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
observer: Arc<Mutex<Option<CallObserver>>>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
}
|
||||
|
||||
impl MockClient {
|
||||
@@ -141,9 +142,14 @@ impl MockClient {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
observer: Arc::new(Mutex::new(None)),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn set_observer(&self, observer: impl Fn(usize) + Send + Sync + 'static) {
|
||||
*self.observer.lock().unwrap() = Some(Arc::new(observer));
|
||||
}
|
||||
@@ -157,9 +163,10 @@ impl LlmClient for MockClient {
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(observer) = self.observer.lock().unwrap().clone() {
|
||||
observer(count);
|
||||
@@ -2065,3 +2072,732 @@ async fn controller_compact_method_publishes_progress_and_clear() {
|
||||
let command = protocol::WorkerCommandEnvelope::new(2);
|
||||
let _ = handle.send(Method::Shutdown { command }).await;
|
||||
}
|
||||
|
||||
const INITIAL_SUMMARY_HEADING: &str = "## Initial compaction summary (full text)\n";
|
||||
const OVERVIEW_HEADING: &str = "## Conversation overview/index\n";
|
||||
const SUMMARY_MARKER: &str = "[Compacted context summary]\n\n";
|
||||
|
||||
// These tests prove transport and durable replacement policy, not whether a live
|
||||
// model preserves meaning. All write_summary outputs below are predetermined.
|
||||
struct SummaryInputFixture {
|
||||
worker: Worker<MockClient, TestStore>,
|
||||
_store_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl SummaryInputFixture {
|
||||
async fn new(manifest_toml: &str, client: MockClient) -> Self {
|
||||
let store_dir = tempfile::tempdir().unwrap();
|
||||
let store = CombinedStore::new(
|
||||
FsStore::new(store_dir.path()).unwrap(),
|
||||
FsWorkerStore::new(store_dir.path().join("pods")).unwrap(),
|
||||
);
|
||||
let engine =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
client,
|
||||
);
|
||||
let mut worker = Worker::new(
|
||||
worker::WorkerManifest::from_toml(manifest_toml).unwrap(),
|
||||
engine,
|
||||
store,
|
||||
worker::WorkerWorkspaceContext::no_workspace(),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
worker::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.enable_worker_metadata_write_through().unwrap();
|
||||
worker.attach_log_writer(Arc::new(worker.log_writer_handle()));
|
||||
Self {
|
||||
worker,
|
||||
_store_dir: store_dir,
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the persisted boundary, then exercise the public rewind API's shared
|
||||
// typed history restore path. No production client or global allocation is
|
||||
// needed. This is restoration coverage, not a cold Runtime restart test.
|
||||
async fn restore_seed(
|
||||
&mut self,
|
||||
history: Vec<session_store::LoggedHistoryEntry>,
|
||||
compacted: bool,
|
||||
) {
|
||||
self.worker
|
||||
.run_text("fixture rewind boundary")
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = self.worker.session_id();
|
||||
let segment_id = self.worker.segment_id();
|
||||
let mut records = self
|
||||
.worker
|
||||
.store()
|
||||
.read_all(session_id, segment_id)
|
||||
.unwrap();
|
||||
let start = records
|
||||
.iter_mut()
|
||||
.find(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. }))
|
||||
.unwrap();
|
||||
match start {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
history: seed,
|
||||
compacted_from,
|
||||
..
|
||||
} => {
|
||||
*seed = history;
|
||||
*compacted_from = compacted.then(|| session_store::SegmentOrigin {
|
||||
segment_id: session_store::new_segment_id(),
|
||||
at_turn_index: 0,
|
||||
});
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
self.worker
|
||||
.store()
|
||||
.create_segment(session_id, segment_id, &records)
|
||||
.unwrap();
|
||||
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&records);
|
||||
let target = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|entry| {
|
||||
matches!(
|
||||
entry.data,
|
||||
protocol::SessionSnapshotEntryData::UserInput { .. }
|
||||
)
|
||||
})
|
||||
.expect("fixture rewind boundary must remain a public user input");
|
||||
self.worker
|
||||
.rewind_to(target.entry_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_entry(
|
||||
item: Item,
|
||||
origin: session_store::LoggedSessionHistoryOrigin,
|
||||
) -> session_store::LoggedHistoryEntry {
|
||||
let mut entry = annotated(item);
|
||||
entry.metadata.origin = origin;
|
||||
entry
|
||||
}
|
||||
|
||||
fn long_summary(label: &str) -> String {
|
||||
format!(
|
||||
"## Completed Tasks\n{label}: investigated the transport boundary.\n\n\
|
||||
## Active Task\nKeep the original user's completion conditions.\n{}\n\
|
||||
## Key Decisions\nDo not infer provenance from pasted markers.\n\n\
|
||||
## User Directives\n{label}_LOAD_BEARING_SENTINEL_AT_END\n\n\
|
||||
## Current Work\nContinue the requested implementation.",
|
||||
"Historical constraints must remain explicit, including the original acceptance criteria.\n"
|
||||
.repeat(70)
|
||||
)
|
||||
}
|
||||
|
||||
fn recent_history(count: usize) -> Vec<session_store::LoggedHistoryEntry> {
|
||||
(0..count)
|
||||
.flat_map(|index| {
|
||||
[
|
||||
annotated(Item::user_message(format!(
|
||||
"recent-user-{index}: {}",
|
||||
"recent progress is not a replacement for the original directive. ".repeat(40)
|
||||
))),
|
||||
annotated(Item::assistant_message(format!("recent-assistant-{index}"))),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn first_compactor_input(request: &Request) -> String {
|
||||
request
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Item::Message {
|
||||
role: agen::Role::User,
|
||||
..
|
||||
} => item
|
||||
.as_text()
|
||||
.filter(|text| text.contains(OVERVIEW_HEADING)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the first compactor request must carry its overview input")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn assert_full_initial_summary(input: &str, summary: &str) {
|
||||
let heading_at = input
|
||||
.find(INITIAL_SUMMARY_HEADING)
|
||||
.expect("full summary heading");
|
||||
let overview_at = input.find(OVERVIEW_HEADING).expect("overview heading");
|
||||
let summary_at = heading_at + INITIAL_SUMMARY_HEADING.len();
|
||||
assert!(summary_at < overview_at);
|
||||
assert!(
|
||||
input[summary_at..].starts_with(summary),
|
||||
"summary must be verbatim"
|
||||
);
|
||||
assert_eq!(
|
||||
input.matches(summary).count(),
|
||||
1,
|
||||
"summary must not be duplicated"
|
||||
);
|
||||
assert!(!input[overview_at..].contains(summary));
|
||||
let opening = summary.chars().take(100).collect::<String>();
|
||||
assert!(
|
||||
!input[overview_at..].contains(&opening),
|
||||
"overview must not carry even a clipped summary"
|
||||
);
|
||||
assert!(!input[overview_at..].contains("LOAD_BEARING_SENTINEL_AT_END"));
|
||||
}
|
||||
|
||||
fn replacement_seed(
|
||||
worker: &Worker<MockClient, TestStore>,
|
||||
) -> Vec<session_store::LoggedHistoryEntry> {
|
||||
worker
|
||||
.store()
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. } => Some(history),
|
||||
_ => None,
|
||||
})
|
||||
.expect("replacement SegmentStart")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes() {
|
||||
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
|
||||
|
||||
let inherited = long_summary("original");
|
||||
assert!(inherited.chars().count() > 4_000);
|
||||
for retained in [0, 1_000_000] {
|
||||
for (warning, deadline) in [(0, 0), (1, 0), (1, 1)] {
|
||||
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
|
||||
"[compaction]",
|
||||
&format!(
|
||||
"[compaction]\noverview_target_tokens = 1\noverview_warning_tokens = {warning}\noverview_deadline_tokens = {deadline}"
|
||||
),
|
||||
);
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("fixture"),
|
||||
write_summary_tool_use_events("replace", "new summary"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
|
||||
let mut seed = vec![summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
|
||||
CompactionSummary,
|
||||
)];
|
||||
let recent = recent_history(40);
|
||||
seed.extend(recent.clone());
|
||||
fixture.restore_seed(seed, true).await;
|
||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||
fixture.worker.attach_alerter(worker::Alerter::new(tx));
|
||||
|
||||
fixture.worker.compact(retained).await.unwrap();
|
||||
|
||||
let requests = observed.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let input = first_compactor_input(&requests[1]);
|
||||
assert_full_initial_summary(&input, &inherited);
|
||||
let alerts = drain(&mut rx);
|
||||
let has_alert = |snippet: &str| {
|
||||
alerts.iter().any(|event| {
|
||||
matches!(event, Event::Alert(alert)
|
||||
if alert.source == protocol::AlertSource::Compactor
|
||||
&& alert.level == protocol::AlertLevel::Warn
|
||||
&& alert.message.contains(snippet))
|
||||
})
|
||||
};
|
||||
assert_eq!(has_alert("larger than expected"), warning != 0);
|
||||
assert_eq!(has_alert("using coarse fallback"), deadline != 0);
|
||||
let replacement = replacement_seed(&fixture.worker);
|
||||
assert_eq!(
|
||||
replacement
|
||||
.iter()
|
||||
.filter(|entry| entry.metadata.origin == CompactionSummary)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(replacement.iter().all(|entry| {
|
||||
!Item::from(entry.item.clone())
|
||||
.as_text()
|
||||
.is_some_and(|text| text.contains(&inherited))
|
||||
}));
|
||||
for entry in &recent {
|
||||
assert_eq!(
|
||||
replacement
|
||||
.iter()
|
||||
.any(|retained_entry| retained_entry == entry),
|
||||
retained != 0,
|
||||
"ordinary retained entries must keep their item and metadata"
|
||||
);
|
||||
}
|
||||
let metrics = session_metrics::read_session_metrics(
|
||||
fixture.worker.store(),
|
||||
fixture.worker.session_id(),
|
||||
)
|
||||
.unwrap();
|
||||
let overview_tokens = metrics
|
||||
.iter()
|
||||
.find(|record| record.metric.name == "compact.overview_tokens")
|
||||
.and_then(|record| record.metric.value)
|
||||
.unwrap();
|
||||
assert!(
|
||||
overview_tokens < inherited.len().div_ceil(4) as f64,
|
||||
"overview accounting must exclude the inherited full summary"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pasted_markers_and_other_derived_context_remain_ordinary_overview_material() {
|
||||
use session_store::LoggedSessionHistoryOrigin::{
|
||||
CompactionSummary, DerivedSummary, LegacyUnknown,
|
||||
};
|
||||
|
||||
for retained in [0, 1_000_000] {
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("fixture"),
|
||||
write_summary_tool_use_events("replace", "replacement"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||
let ordinary = vec![
|
||||
summary_entry(
|
||||
Item::system_message("[Session TaskStore snapshot]\nactive task"),
|
||||
DerivedSummary,
|
||||
),
|
||||
summary_entry(
|
||||
Item::system_message("auto-read file source material"),
|
||||
DerivedSummary,
|
||||
),
|
||||
summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}pasted system marker")),
|
||||
LegacyUnknown,
|
||||
),
|
||||
summary_entry(
|
||||
Item::user_message(format!("{SUMMARY_MARKER}pasted user marker")),
|
||||
LegacyUnknown,
|
||||
),
|
||||
summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}later derived marker")),
|
||||
DerivedSummary,
|
||||
),
|
||||
];
|
||||
fixture.restore_seed(ordinary.clone(), true).await;
|
||||
assert!(
|
||||
fixture
|
||||
.worker
|
||||
.session_history()
|
||||
.entries()
|
||||
.iter()
|
||||
.all(|entry| entry.annotation.origin != CompactionSummary)
|
||||
);
|
||||
|
||||
fixture.worker.compact(retained).await.unwrap();
|
||||
|
||||
let input = first_compactor_input(&observed.requests()[1]);
|
||||
assert!(!input.contains(INITIAL_SUMMARY_HEADING));
|
||||
let overview = input.split_once(OVERVIEW_HEADING).unwrap().1;
|
||||
let replacement = replacement_seed(&fixture.worker);
|
||||
for entry in &ordinary {
|
||||
let text = Item::from(entry.item.clone())
|
||||
.as_text()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
overview.contains(&text),
|
||||
retained == 0,
|
||||
"ordinary overview source: {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
replacement
|
||||
.iter()
|
||||
.any(|retained_entry| retained_entry == entry),
|
||||
retained != 0,
|
||||
"other derived context and pasted markers must follow ordinary retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker()
|
||||
{
|
||||
use session_store::LoggedSessionHistoryOrigin::{
|
||||
CompactionSummary, DerivedSummary, LegacyUnknown,
|
||||
};
|
||||
|
||||
let inherited = long_summary("legacy");
|
||||
// Each false case lacks one trusted construction condition; marker text
|
||||
// alone must never promote ordinary context into inherited Host material.
|
||||
for case in [
|
||||
"trusted",
|
||||
"no-lineage",
|
||||
"not-first",
|
||||
"unknown-origin",
|
||||
"user-role",
|
||||
"no-marker",
|
||||
] {
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("fixture"),
|
||||
write_summary_tool_use_events("replace", "replacement"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||
let text = if case == "no-marker" {
|
||||
inherited.clone()
|
||||
} else {
|
||||
format!("{SUMMARY_MARKER}{inherited}")
|
||||
};
|
||||
let item = if case == "user-role" {
|
||||
Item::user_message(text)
|
||||
} else {
|
||||
Item::system_message(text)
|
||||
};
|
||||
let origin = if case == "unknown-origin" {
|
||||
LegacyUnknown
|
||||
} else {
|
||||
DerivedSummary
|
||||
};
|
||||
let candidate = summary_entry(item, origin);
|
||||
let candidate_id = candidate.metadata.entry_id.clone();
|
||||
let mut history = vec![candidate];
|
||||
if case == "not-first" {
|
||||
history.insert(
|
||||
0,
|
||||
annotated(Item::system_message("unrelated first system context")),
|
||||
);
|
||||
}
|
||||
history.extend(recent_history(20));
|
||||
fixture.restore_seed(history, case != "no-lineage").await;
|
||||
let restored = fixture
|
||||
.worker
|
||||
.session_history()
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.annotation.entry_id == candidate_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.annotation.origin == CompactionSummary,
|
||||
case == "trusted",
|
||||
"{case}"
|
||||
);
|
||||
// A restore-time upgrade is an in-memory interpretation of the trusted
|
||||
// seed; it must not silently rewrite the historical durable record.
|
||||
assert!(
|
||||
replacement_seed(&fixture.worker)
|
||||
.iter()
|
||||
.all(|entry| entry.metadata.origin != CompactionSummary)
|
||||
);
|
||||
|
||||
fixture.worker.compact(0).await.unwrap();
|
||||
|
||||
let input = first_compactor_input(&observed.requests()[1]);
|
||||
if case == "trusted" {
|
||||
assert_full_initial_summary(&input, &inherited);
|
||||
} else {
|
||||
assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries()
|
||||
{
|
||||
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
|
||||
|
||||
let original_goal =
|
||||
"CHAIN_ORIGINAL_USER_GOAL: finish only after all explicit acceptance conditions hold";
|
||||
// Every predetermined output keeps the original directive. The assertions
|
||||
// prove that complete predecessors reach later requests, not that a model
|
||||
// chose to preserve this directive on its own.
|
||||
let summaries = (0..3)
|
||||
.map(|index| {
|
||||
format!(
|
||||
"{}\n{original_goal}",
|
||||
long_summary(&format!("generation-{index}"))
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let initial = format!("{}\n{original_goal}", long_summary("initial"));
|
||||
let mut responses = vec![single_text_events("seed response")];
|
||||
for (index, summary) in summaries.iter().enumerate() {
|
||||
responses.push(single_text_events(
|
||||
"acknowledge new directive and temporary recovery",
|
||||
));
|
||||
responses.push(write_summary_tool_use_events(
|
||||
&format!("summary-{index}"),
|
||||
summary,
|
||||
));
|
||||
responses.push(single_text_events("done"));
|
||||
}
|
||||
let client = MockClient::new(responses);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||
fixture
|
||||
.restore_seed(
|
||||
vec![summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}{initial}")),
|
||||
CompactionSummary,
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let original_session = fixture.worker.session_id();
|
||||
let mut predecessor = fixture.worker.segment_id();
|
||||
for (index, summary) in summaries.iter().enumerate() {
|
||||
let new_directive = format!(
|
||||
"NEW_USER_DIRECTIVE_{index}: do not merge. Temporarily recover the interrupted work and collect SubWorkers; this does not retract the original goal."
|
||||
);
|
||||
fixture.worker.run_text(&new_directive).await.unwrap();
|
||||
let first_request = observed.requests().len();
|
||||
fixture.worker.compact(0).await.unwrap();
|
||||
let input = first_compactor_input(&observed.requests()[first_request]);
|
||||
let prior = if index == 0 {
|
||||
&initial
|
||||
} else {
|
||||
&summaries[index - 1]
|
||||
};
|
||||
assert_full_initial_summary(&input, prior);
|
||||
assert!(input.contains(original_goal));
|
||||
assert!(input.contains(&new_directive));
|
||||
let seed = replacement_seed(&fixture.worker);
|
||||
let inherited = seed
|
||||
.iter()
|
||||
.filter(|entry| entry.metadata.origin == CompactionSummary)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(inherited.len(), 1);
|
||||
assert_eq!(
|
||||
Item::from(inherited[0].item.clone()).as_text().unwrap(),
|
||||
format!("{SUMMARY_MARKER}{summary}")
|
||||
);
|
||||
for old in &summaries[..index] {
|
||||
assert!(
|
||||
fixture
|
||||
.worker
|
||||
.history()
|
||||
.iter()
|
||||
.all(|item| !item.as_text().is_some_and(|text| text.contains(old)))
|
||||
);
|
||||
}
|
||||
let records = fixture
|
||||
.worker
|
||||
.store()
|
||||
.read_all(original_session, fixture.worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart {
|
||||
compacted_from: Some(origin), ..
|
||||
} if origin.segment_id == predecessor))
|
||||
);
|
||||
let restored = session_store::restore(
|
||||
fixture.worker.store(),
|
||||
original_session,
|
||||
fixture.worker.segment_id(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.as_text()
|
||||
.is_some_and(|text| text == format!("{SUMMARY_MARKER}{summary}"))
|
||||
}));
|
||||
predecessor = fixture.worker.segment_id();
|
||||
}
|
||||
assert_eq!(observed.requests().len(), 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate() {
|
||||
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
|
||||
|
||||
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
|
||||
"[compaction]",
|
||||
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 0\nfinal_reserve_tokens = 0\noverview_target_tokens = 1",
|
||||
);
|
||||
for include_summary in [false, true] {
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("fixture"),
|
||||
write_summary_tool_use_events("replace", "replacement"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
|
||||
let summary =
|
||||
"Preserve the user's goal and every explicit completion condition.\n".repeat(3_000);
|
||||
assert!(
|
||||
agen::token_counter::total_tokens(&[Item::user_message(&summary)], &[]).tokens > 10_000
|
||||
);
|
||||
let mut history = Vec::new();
|
||||
if include_summary {
|
||||
history.push(summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}{summary}")),
|
||||
CompactionSummary,
|
||||
));
|
||||
}
|
||||
history.extend(recent_history(20));
|
||||
fixture.restore_seed(history, true).await;
|
||||
let source = fixture.worker.segment_id();
|
||||
let source_history = fixture.worker.history();
|
||||
let result = fixture.worker.compact(0).await;
|
||||
if include_summary {
|
||||
let error = result.expect_err("mandatory input over hard cap must fail, not truncate");
|
||||
assert!(
|
||||
matches!(error, WorkerError::CompactCancelled)
|
||||
|| error.to_string().contains("input occupancy"),
|
||||
"unexpected hard-cap failure: {error:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
observed.requests().len(),
|
||||
1,
|
||||
"no compactor request may cross the hard cap"
|
||||
);
|
||||
assert_eq!(fixture.worker.segment_id(), source);
|
||||
assert_eq!(fixture.worker.history(), source_history);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.worker
|
||||
.store()
|
||||
.read_by_name("test-worker")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.active
|
||||
.unwrap()
|
||||
.segment_id,
|
||||
Some(source)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.worker
|
||||
.store()
|
||||
.list_segments(fixture.worker.session_id())
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
} else {
|
||||
result.expect("the same bounded overview without inherited input fits the cap");
|
||||
assert_eq!(observed.requests().len(), 3);
|
||||
assert_ne!(fixture.worker.segment_id(), source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summary_output_still_over_hard_max_after_rewrite_fails_without_activation() {
|
||||
let oversized = long_summary("oversized");
|
||||
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
|
||||
"[compaction]",
|
||||
"[compaction]\nsummary_target_tokens = 50\nsummary_max_tokens = 100",
|
||||
);
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("seed"),
|
||||
write_summary_tool_use_events("first-output", &oversized),
|
||||
single_text_events("done"),
|
||||
write_summary_tool_use_events("rewrite-output", &oversized),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
|
||||
fixture.worker.run_text("user goal").await.unwrap();
|
||||
let source = fixture.worker.segment_id();
|
||||
let history = fixture.worker.history();
|
||||
|
||||
let error = fixture.worker.compact(0).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkerError::CompactSummaryTooLarge { max: 100, .. }
|
||||
));
|
||||
assert_eq!(fixture.worker.segment_id(), source);
|
||||
assert_eq!(fixture.worker.history(), history);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.worker
|
||||
.store()
|
||||
.read_by_name("test-worker")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.active
|
||||
.unwrap()
|
||||
.segment_id,
|
||||
Some(source)
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.worker
|
||||
.store()
|
||||
.list_segments(fixture.worker.session_id())
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
observed
|
||||
.requests()
|
||||
.iter()
|
||||
.any(|request| request.items.iter().any(|item| item
|
||||
.as_text()
|
||||
.is_some_and(|text| text.contains("Rewrite it now with `write_summary`"))))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_warning_is_durable_and_keeps_full_summary_in_first_model_request() {
|
||||
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
|
||||
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
|
||||
"[compaction]",
|
||||
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 9999\nfinal_reserve_tokens = 9999",
|
||||
);
|
||||
let client = MockClient::new(vec![
|
||||
single_text_events("fixture"),
|
||||
write_summary_tool_use_events("summary", "replacement"),
|
||||
single_text_events("done"),
|
||||
]);
|
||||
let observed = client.clone();
|
||||
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
|
||||
let inherited = long_summary("budget-warning");
|
||||
fixture
|
||||
.restore_seed(
|
||||
vec![summary_entry(
|
||||
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
|
||||
CompactionSummary,
|
||||
)],
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let (tx, mut rx) = broadcast::channel::<Event>(128);
|
||||
fixture.worker.attach_alerter(worker::Alerter::new(tx));
|
||||
fixture.worker.compact(0).await.unwrap();
|
||||
let requests = observed.requests();
|
||||
assert_full_initial_summary(&first_compactor_input(&requests[1]), &inherited);
|
||||
let warning = requests[1]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(Item::as_text)
|
||||
.find(|text| text.starts_with("[Compact worker budget warning]"))
|
||||
.expect("normal internal Worker must preserve the budget interceptor warning");
|
||||
assert!(warning.contains("full initial summary and segment"));
|
||||
assert!(warning.contains("changes/retractions"));
|
||||
assert!(
|
||||
requests[2]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(Item::as_text)
|
||||
.any(|text| text == warning),
|
||||
"warning must remain in history, not be a transient request insertion"
|
||||
);
|
||||
assert!(
|
||||
drain(&mut rx)
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::Alert(alert)
|
||||
if alert.message.contains("compact worker context budget is low")))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Durable operational delivery and startup fencing, independent of Ticket handling.
|
||||
//!
|
||||
//! All delivery triggers use the same pending-input scan: current actionable delegation,
|
||||
//! committed input origin and open case, never Queue/open state alone. Waiting is not a
|
||||
//! resend reason; comments, self-reports and case/routing history are not new requests.
|
||||
//! Migration preserves evidence but creates origins only for reconciled assignment-specific
|
||||
//! legacy Queue fences. Unknown/history/done/released assignments are not fresh work.
|
||||
//!
|
||||
//! Reserve, prepare and acknowledge recheck delegation, singleton and live binding under
|
||||
//! IMMEDIATE ownership. Persist the entire rendered request before Runtime I/O, then reuse
|
||||
//! its request ID and content on response loss. Send outside the transaction; acknowledgement
|
||||
//! proves Runtime acceptance, not model consideration, execution, handling or fulfillment.
|
||||
//! Async callers offload whole helpers, never split authorization/receipt/CAS/audit across awaits.
|
||||
//!
|
||||
//! Orchestrator starts require the observed assignment and the request's authenticated binding,
|
||||
//! not a newly resolved binding or model role. Saved start fences extend existing allocation
|
||||
//! receipts through reserve/retry, bind, finalization and recovery. Unknown external outcomes
|
||||
//! retain resource fences for reconciliation, not a second start. Manual authorized starts
|
||||
//! omit delegation; unresolved dependencies are diagnostics, not blanket start gates.
|
||||
//! Clear/reassign does not stop or transfer running work; reopen alone revives no old delegation.
|
||||
//! Immutable operational receipts are deleted only by the guarded Workspace purge.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketAssignmentDelivery {
|
||||
pub ticket_id: String,
|
||||
pub assignment_id: String,
|
||||
pub input_event_id: String,
|
||||
pub delivery_id: String,
|
||||
pub request_id: String,
|
||||
pub waiting_reason: Option<String>,
|
||||
pub next_action: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE ticket_assignment_delivery_origins (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, assignment_id TEXT NOT NULL,
|
||||
input_event_id TEXT NOT NULL, after_ticket_event_index INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,assignment_id),
|
||||
FOREIGN KEY(workspace_id,ticket_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id)
|
||||
);
|
||||
CREATE TABLE ticket_assignment_deliveries (
|
||||
workspace_id TEXT NOT NULL, assignment_id TEXT NOT NULL, input_event_id TEXT NOT NULL,
|
||||
delivery_id TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(json_valid(payload_json)),
|
||||
reserved_source_json TEXT NOT NULL CHECK(json_valid(reserved_source_json)),
|
||||
PRIMARY KEY(workspace_id,assignment_id,input_event_id), UNIQUE(workspace_id,delivery_id),
|
||||
FOREIGN KEY(workspace_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id)
|
||||
);
|
||||
CREATE TABLE ticket_assignment_delivery_requests (
|
||||
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, content TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,delivery_id),
|
||||
FOREIGN KEY(workspace_id,delivery_id) REFERENCES ticket_assignment_deliveries(workspace_id,delivery_id)
|
||||
);
|
||||
CREATE TRIGGER ticket_assignment_delivery_request_no_update BEFORE UPDATE ON ticket_assignment_delivery_requests
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery request is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_request_no_delete BEFORE DELETE ON ticket_assignment_delivery_requests
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery request is immutable'); END;
|
||||
CREATE TABLE ticket_assignment_delivery_acknowledgements (
|
||||
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL,
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)), PRIMARY KEY(workspace_id,delivery_id),
|
||||
FOREIGN KEY(workspace_id,delivery_id) REFERENCES ticket_assignment_deliveries(workspace_id,delivery_id)
|
||||
);
|
||||
CREATE TABLE ticket_assignment_operation_fences (
|
||||
workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, orchestrator_assignment_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, agent_key TEXT NOT NULL,
|
||||
authentication_binding_id TEXT NOT NULL, source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
PRIMARY KEY(workspace_id,operation_id),
|
||||
FOREIGN KEY(workspace_id,operation_id) REFERENCES ticket_assignment_operations(workspace_id,operation_id),
|
||||
FOREIGN KEY(workspace_id,orchestrator_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id)
|
||||
);
|
||||
CREATE TRIGGER ticket_assignment_delivery_origin_insert
|
||||
AFTER INSERT ON typed_ticket_assignment_tracking_history
|
||||
WHEN json_extract(NEW.source_context_json,'$.kind')='assignment'
|
||||
BEGIN
|
||||
INSERT INTO ticket_assignment_delivery_origins
|
||||
VALUES(NEW.workspace_id,NEW.ticket_id,NEW.assignment_id,'assignment-event:' || NEW.event_id,
|
||||
COALESCE((SELECT MAX(event_index) FROM typed_ticket_events
|
||||
WHERE workspace_id=NEW.workspace_id AND ticket_id=NEW.ticket_id),-1));
|
||||
END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_payload_no_update BEFORE UPDATE ON ticket_assignment_deliveries
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery payload is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_payload_no_delete BEFORE DELETE ON ticket_assignment_deliveries
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery payload is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_ack_no_update BEFORE UPDATE ON ticket_assignment_delivery_acknowledgements
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery acknowledgement is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_ack_no_delete BEFORE DELETE ON ticket_assignment_delivery_acknowledgements
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment delivery acknowledgement is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_operation_fence_no_update BEFORE UPDATE ON ticket_assignment_operation_fences
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment startup fence is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_operation_fence_no_delete BEFORE DELETE ON ticket_assignment_operation_fences
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment startup fence is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_origin_no_update BEFORE UPDATE ON ticket_assignment_delivery_origins
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment input origin is immutable'); END;
|
||||
CREATE TRIGGER ticket_assignment_delivery_origin_no_delete BEFORE DELETE ON ticket_assignment_delivery_origins
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment input origin is immutable'); END;
|
||||
"#;
|
||||
|
||||
/// Additive Workspace schema only. Native pre-upgrade assignments use their
|
||||
/// committed initial audit time to exclude earlier Ticket inputs; ambiguous
|
||||
/// legacy relationships are NOT treated as fresh delegations. Migration inputs
|
||||
/// retain the exact Queue fence already reconciled by Ticket schema 11.
|
||||
pub(super) fn install(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(SCHEMA_SQL)?;
|
||||
conn.execute_batch(r#"
|
||||
INSERT INTO ticket_assignment_delivery_origins
|
||||
SELECT h.workspace_id,h.ticket_id,h.assignment_id,'assignment-event:' || h.event_id,
|
||||
COALESCE((SELECT MAX(e.event_index) FROM typed_ticket_events e
|
||||
WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id AND e.at<h.at),-1)
|
||||
FROM typed_ticket_assignment_tracking_history h
|
||||
WHERE json_extract(h.source_context_json,'$.kind')='assignment';
|
||||
INSERT INTO ticket_assignment_delivery_origins
|
||||
SELECT h.workspace_id,h.ticket_id,h.assignment_id,'assignment-event:' || h.event_id,
|
||||
(SELECT MAX(e.event_index) FROM typed_ticket_events e
|
||||
JOIN typed_ticket_event_attributes f ON f.workspace_id=e.workspace_id AND f.ticket_id=e.ticket_id AND f.event_index=e.event_index
|
||||
WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id
|
||||
AND f.key='orchestrator_assignment_id' AND f.value=h.assignment_id
|
||||
AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued')))
|
||||
FROM typed_ticket_assignment_tracking_history h
|
||||
WHERE json_extract(h.source_context_json,'$.kind')='migration'
|
||||
AND EXISTS(SELECT 1 FROM typed_ticket_events e JOIN typed_ticket_event_attributes f
|
||||
ON f.workspace_id=e.workspace_id AND f.ticket_id=e.ticket_id AND f.event_index=e.event_index
|
||||
WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id
|
||||
AND f.key='orchestrator_assignment_id' AND f.value=h.assignment_id
|
||||
AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued')));
|
||||
"#)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const INPUTS_SQL: &str = r#"
|
||||
WITH eligible AS (
|
||||
SELECT a.*,o.input_event_id,o.after_ticket_event_index
|
||||
FROM ticket_current_worker_assignments c
|
||||
JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id
|
||||
JOIN ticket_assignment_delivery_origins o ON o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id
|
||||
JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
|
||||
WHERE a.workspace_id=?1 AND a.role='orchestrator' AND a.principal_kind='workspace_agent'
|
||||
AND a.principal_id='workspace-orchestrator' AND t.workflow_state='open'
|
||||
AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
|
||||
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r
|
||||
WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id)
|
||||
), inputs AS (
|
||||
SELECT ticket_id,assignment_id,input_event_id,tracking_json,-1 AS input_order FROM eligible
|
||||
UNION ALL
|
||||
SELECT a.ticket_id,a.assignment_id,'ticket-event:' || e.event_index,a.tracking_json,e.event_index
|
||||
FROM eligible a JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id
|
||||
WHERE e.event_index>a.after_ticket_event_index
|
||||
-- Continuing request intent is explicit: fenced assignment answers or item content edits.
|
||||
-- Ordinary comments and all case/routing/handling history are not new requests.
|
||||
AND e.kind IN ('assignment_answer','item_edit')
|
||||
AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=a.assignment_id))
|
||||
AND e.kind NOT LIKE 'assignment_tracking%'
|
||||
AND e.kind NOT LIKE 'assignment_delivery%'
|
||||
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
|
||||
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id)
|
||||
)
|
||||
SELECT i.ticket_id,i.assignment_id,i.input_event_id,
|
||||
json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json
|
||||
FROM inputs i
|
||||
LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=i.assignment_id AND d.input_event_id=i.input_event_id
|
||||
WHERE NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack
|
||||
WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id)
|
||||
AND (?3 IS NULL OR i.assignment_id=?3) AND (?4 IS NULL OR i.input_event_id=?4)
|
||||
"#;
|
||||
|
||||
fn decode_payload(payload: &str) -> Result<TicketAssignmentDelivery> {
|
||||
serde_json::from_str(payload).map_err(|e| Error::Store(e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn list_in_tx(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>> {
|
||||
inputs_in_tx(conn, workspace_id, limit, None, None)
|
||||
}
|
||||
fn inputs_in_tx(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
assignment_id: Option<&str>,
|
||||
input_event_id: Option<&str>,
|
||||
) -> Result<Vec<TicketAssignmentDelivery>> {
|
||||
let sql = format!("{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order LIMIT ?2");
|
||||
let mut statement = conn.prepare(&sql)?;
|
||||
let rows = statement.query_map(
|
||||
params![workspace_id, limit as i64, assignment_id, input_event_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
rows.map(|row| {
|
||||
let (ticket_id, assignment_id, input_event_id, waiting_reason, next_action, payload) = row?;
|
||||
if let Some(payload) = payload {
|
||||
return decode_payload(&payload);
|
||||
}
|
||||
// Hash the identities, not mutable handling. Scope is Workspace-qualified.
|
||||
let identities = serde_json::to_string(&(workspace_id, &assignment_id, &input_event_id))
|
||||
.map_err(|e| Error::Store(e.to_string()))?;
|
||||
let digest = Sha256::digest(identities.as_bytes());
|
||||
let hex = digest
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let delivery_id = format!("assignment-delivery:{hex}");
|
||||
Ok(TicketAssignmentDelivery {
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
input_event_id,
|
||||
request_id: delivery_id.clone(),
|
||||
delivery_id,
|
||||
waiting_reason,
|
||||
next_action,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_guard(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<()> {
|
||||
assignment_tracking::require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.authentication_binding_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn require_current(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<()> {
|
||||
let current:bool=conn.query_row(r#"SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c
|
||||
JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id
|
||||
JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
|
||||
WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role='orchestrator'
|
||||
AND a.principal_kind='workspace_agent' AND a.principal_id='workspace-orchestrator'
|
||||
AND t.workflow_state='open' AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
|
||||
AND EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins o WHERE o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id)
|
||||
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id))"#,params![workspace_id,ticket_id,assignment_id],|r|r.get(0))?;
|
||||
if !current {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Assignment is no longer a current actionable delegation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn reserve(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
assignment_id: &str,
|
||||
input_event_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<TicketAssignmentDelivery> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let ticket_id:String=tx.query_row("SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,assignment_id],|r|r.get(0))?;
|
||||
require_current(&tx,workspace_id,&ticket_id,assignment_id)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace_id,assignment_id,input_event_id],|r|r.get(0)).optional()?;
|
||||
if let Some(saved)=saved {let result=decode_payload(&saved)?;tx.commit()?;return Ok(result);}
|
||||
// A caller cannot fabricate an input. Restrict the same common query by
|
||||
// exact assignment/input identity rather than trusting a scan snapshot.
|
||||
let candidates=inputs_in_tx(&tx,workspace_id,1,Some(assignment_id),Some(input_event_id))?;
|
||||
let delivery=candidates.into_iter().find(|d|d.assignment_id==assignment_id && d.input_event_id==input_event_id)
|
||||
.ok_or_else(||Error::TicketAssignmentConflict("Assignment input is not pending".into()))?;
|
||||
tx.execute("INSERT INTO ticket_assignment_deliveries VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,assignment_id,input_event_id,delivery.delivery_id,serde_json::to_string(&delivery).map_err(|e|Error::Store(e.to_string()))?,serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?])?;
|
||||
tx.commit()?;
|
||||
Ok(delivery)
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist the exact rendered Runtime request before I/O. A losing renderer or
|
||||
/// retry receives the original bytes, not a new payload under the same ID.
|
||||
pub(super) fn prepare_content(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
content: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<String> {
|
||||
if content.is_empty() || content.len() > 262144 || content.contains('\0') {
|
||||
return Err(Error::InvalidInput(
|
||||
"Assignment delivery content must be nonempty, NUL-free and at most 262144 bytes"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?;
|
||||
let delivery=decode_payload(&payload)?;
|
||||
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
let result=if let Some(saved)=saved {saved} else {
|
||||
tx.execute("INSERT INTO ticket_assignment_delivery_requests VALUES(?1,?2,?3)",params![workspace_id,delivery_id,content])?;
|
||||
content.to_owned()
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ack(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<()> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?;
|
||||
let delivery=decode_payload(&payload)?;
|
||||
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
|
||||
let source_json=serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?;
|
||||
let saved:Option<String>=tx.query_row("SELECT source_json FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
if let Some(saved)=saved {
|
||||
if saved!=source_json {return Err(Error::TicketAssignmentConflict("Delivery acknowledgement source differs from saved receipt".into()));}
|
||||
} else {tx.execute("INSERT INTO ticket_assignment_delivery_acknowledgements VALUES(?1,?2,?3)",params![workspace_id,delivery_id,source_json])?;}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Must run before receipt replay as well as every bind/finalize write. Absence
|
||||
/// is the existing manual reservation contract, not inferred delegation.
|
||||
pub(super) fn operation_guard(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<()> {
|
||||
let fence=conn.query_row("SELECT o.ticket_id,f.orchestrator_assignment_id,f.runtime_id,f.worker_id,f.agent_key,f.authentication_binding_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?))).optional()?;
|
||||
if let Some((ticket_id, assignment_id, runtime_id, worker_id, agent_key, binding_id)) = fence {
|
||||
assignment_tracking::require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
&agent_key,
|
||||
&binding_id,
|
||||
)?;
|
||||
require_current(conn, workspace_id, &ticket_id, &assignment_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn save_operation_fence(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
existing: bool,
|
||||
) -> Result<()> {
|
||||
source_guard(conn, workspace_id, source)?;
|
||||
require_current(conn, workspace_id, ticket_id, assignment_id)?;
|
||||
let source_json = serde_json::to_string(source).map_err(|e| Error::Store(e.to_string()))?;
|
||||
let saved:Option<(String,String)>=conn.query_row("SELECT orchestrator_assignment_id,source_json FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
|
||||
if let Some(saved) = saved {
|
||||
if saved != (assignment_id.to_owned(), source_json) {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Startup operation assignment/source differs from saved fence".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if existing {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Cannot add a delegation fence to an existing manual operation".into(),
|
||||
));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_operation_fences VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace_id,
|
||||
operation_id,
|
||||
assignment_id,
|
||||
source.runtime_id,
|
||||
source.worker_id,
|
||||
source.agent_key,
|
||||
source.authentication_binding_id,
|
||||
source_json
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Same input selection as delivery, plus immutable delivered history. Never
|
||||
// SELECT payload/source/content into the public diagnostic projection.
|
||||
const DIAGNOSTIC_INPUTS_SQL: &str = r#"
|
||||
WITH inputs AS (
|
||||
SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o
|
||||
WHERE o.workspace_id=?1 AND o.assignment_id=?3
|
||||
UNION
|
||||
SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END
|
||||
FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3
|
||||
UNION
|
||||
SELECT 'ticket-event:' || e.event_index,e.event_index
|
||||
FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id
|
||||
WHERE o.workspace_id=?1 AND o.ticket_id=?2 AND o.assignment_id=?3 AND e.event_index>o.after_ticket_event_index
|
||||
AND e.kind IN ('assignment_answer','item_edit')
|
||||
AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=?3))
|
||||
AND e.kind NOT LIKE 'assignment_tracking%' AND e.kind NOT LIKE 'assignment_delivery%'
|
||||
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
|
||||
-- Retain historical input identity after done/release. A later delegation's
|
||||
-- origin bounds ordinary events; an explicit answer's attribute is authoritative.
|
||||
AND e.event_index<=COALESCE((SELECT MIN(later.after_ticket_event_index) FROM ticket_assignment_delivery_origins later
|
||||
JOIN ticket_worker_assignments la ON la.workspace_id=later.workspace_id AND la.assignment_id=later.assignment_id
|
||||
JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id
|
||||
WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807)
|
||||
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3)
|
||||
)
|
||||
"#;
|
||||
|
||||
pub(super) fn diagnostics_page(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
after_input_event_id: Option<&str>,
|
||||
after_start_operation_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<server_api::TicketAssignmentDiagnosticsPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(Error::InvalidInput(
|
||||
"Diagnostic page limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
// One read snapshot for current assignment, evidence and cursor validation.
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let assignment = ticket::sqlite_ticket_assignment_tracking(&tx,workspace_id,ticket_id,assignment_id)
|
||||
.map_err(|e| Error::Store(e.to_string()))?.ok_or_else(|| Error::InvalidInput("Assignment does not belong to this Ticket".into()))?;
|
||||
let known: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3)",params![workspace_id,ticket_id,assignment_id],|r| r.get(0))?;
|
||||
let actionable = match require_current(&tx,workspace_id,ticket_id,assignment_id) {
|
||||
Ok(()) => true,
|
||||
Err(Error::TicketAssignmentConflict(_)) => false,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let after = match after_input_event_id {
|
||||
None => -2,
|
||||
Some(id) => tx.query_row(&format!("{DIAGNOSTIC_INPUTS_SQL} SELECT input_order FROM inputs WHERE input_event_id=?5"),params![workspace_id,ticket_id,assignment_id,actionable,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Input cursor does not belong to this assignment".into()))?,
|
||||
};
|
||||
let sql = format!("{DIAGNOSTIC_INPUTS_SQL} SELECT i.input_event_id,d.delivery_id,EXISTS(SELECT 1 FROM ticket_assignment_delivery_requests req WHERE req.workspace_id=?1 AND req.delivery_id=d.delivery_id),EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id) FROM inputs i LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=?3 AND d.input_event_id=i.input_event_id WHERE i.input_order>?5 ORDER BY i.input_order LIMIT ?6");
|
||||
let mut stmt = tx.prepare(&sql)?;
|
||||
let mut inputs = stmt.query_map(params![workspace_id,ticket_id,assignment_id,actionable,after,(limit+1) as i64],|r| {
|
||||
let delivery_id: Option<String> = r.get(1)?;
|
||||
let prepared: bool = r.get(2)?;
|
||||
let ack: bool = r.get(3)?;
|
||||
use server_api::TicketAssignmentInputDeliveryState::*;
|
||||
let notification = if ack { RuntimeAccepted } else if prepared { RequestPersisted } else if delivery_id.is_some() { Reserved } else { NotReserved };
|
||||
Ok(server_api::TicketAssignmentInputDiagnostic { input_event_id:r.get(0)?, delivery_id,notification,acceptance_outcome_unknown:prepared && !ack })
|
||||
})?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
let more = inputs.len()>limit;
|
||||
inputs.truncate(limit);
|
||||
let next_input_event_id = if more { inputs.last().map(|i| i.input_event_id.clone()) } else { None };
|
||||
let after_start = match after_start_operation_id {
|
||||
None => 0,
|
||||
Some(id) => tx.query_row("SELECT f.rowid FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.orchestrator_assignment_id=?3 AND o.ticket_id=?2 AND f.operation_id=?4",params![workspace_id,ticket_id,assignment_id,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Start cursor does not belong to this assignment".into()))?,
|
||||
};
|
||||
let mut stmt = tx.prepare("SELECT f.operation_id,o.claim_state,o.assignment_id,o.runtime_id,o.worker_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND o.ticket_id=?2 AND f.orchestrator_assignment_id=?3 AND f.rowid>?4 ORDER BY f.rowid LIMIT ?5")?;
|
||||
let mut starts = stmt.query_map(params![workspace_id,ticket_id,assignment_id,after_start,(limit+1) as i64],|r| Ok(server_api::TicketAssignmentStartDiagnostic { operation_id:r.get(0)?,claim_state:r.get(1)?,assignment_id:r.get(2)?,runtime_id:r.get(3)?,worker_id:r.get(4)?,actual_processing_unknown:true }))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
let more = starts.len()>limit;
|
||||
starts.truncate(limit);
|
||||
let next_start_operation_id = if more { starts.last().map(|i| i.operation_id.clone()) } else { None };
|
||||
Ok(server_api::TicketAssignmentDiagnosticsPage { assignment_id:assignment_id.into(),current:assignment.current,input_origin_known:known,actionable,inputs,next_input_event_id,starts,next_start_operation_id })
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
//! Workspace adapter: Ticket owns assignment identities, current pointers, handling,
|
||||
//! audit and receipts, including tables with historical Worker-assignment names.
|
||||
//! Assign/reissue creates a new identity; self-report changes that row using observed
|
||||
//! event CAS and a complete payload. Exact retry returns a receipt, not a fresh observation.
|
||||
//!
|
||||
//! Backend constructs actor/source authority, never from model roles or readable audit
|
||||
//! text. The whole IMMEDIATE mutation fences the singleton and the binding actually
|
||||
//! used for authentication, including receipt replay; Worker replacement invalidates
|
||||
//! the old sender. Account authoring keeps authenticated Account/User and Workspace
|
||||
//! permissions without requiring a Runtime. Async callers offload the entire helper.
|
||||
//!
|
||||
//! Case judgments, handling, delivery/start evidence, Worker lifecycle and grants are
|
||||
//! separate. Close/reopen does not change handling; the current Orchestrator can report
|
||||
//! results after close without an active-work grant. Done releases no work or resources.
|
||||
//! A continuing answer fences current assignment/event and saves input/receipt together;
|
||||
//! it neither changes handling nor creates an assignment. Done needs explicit reissue.
|
||||
//!
|
||||
//! Reads and cursors stay bounded and Workspace/Ticket/assignment scoped. Diagnostics
|
||||
//! expose neither rendered requests nor binding credentials and attest no model processing.
|
||||
//! Legacy unknown handling remains unknown: a retained Assign event can be the first
|
||||
//! report's CAS baseline, not invented waiting or a handling-history cursor.
|
||||
use super::*;
|
||||
|
||||
/// Backend-authenticated attribution. Readable assigned_by/actor is audit
|
||||
/// payload, not this authority context. Never decoded from model/API input.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketOrchestratorAssignmentSourceContext {
|
||||
Runtime {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
agent_key: String,
|
||||
authentication_binding_id: String,
|
||||
},
|
||||
Account {
|
||||
account_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn require_runtime_source(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
agent_key: &str,
|
||||
binding_id: &str,
|
||||
) -> Result<()> {
|
||||
let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?;
|
||||
if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn require_assignment_source(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
source: &TicketOrchestratorAssignmentSourceContext,
|
||||
) -> Result<()> {
|
||||
match source {
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
} => require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
),
|
||||
TicketOrchestratorAssignmentSourceContext::Account {
|
||||
account_id,
|
||||
user_id,
|
||||
} => {
|
||||
// Backend already authenticates Account/Workspace permission. Do not
|
||||
// infer a model role or require a Runtime for account authoring.
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE account_id=?1)",
|
||||
[account_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists || user_id.trim().is_empty() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Assignment account source is not a valid Backend actor".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
update: &TicketAssignmentTrackingUpdate,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<TicketAssignmentTrackingProjection> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
// Every call, including a receipt replay, fences the authenticated
|
||||
// Runtime binding and singleton snapshot under the write lock. Assignment
|
||||
// replacement/removal may be replayed, Worker replacement may not.
|
||||
require_runtime_source(
|
||||
&tx,
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.authentication_binding_id,
|
||||
)?;
|
||||
let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
&tx,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
update,
|
||||
source,
|
||||
)
|
||||
.map_err(|error| Error::TicketAssignmentConflict(error.to_string()))?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn get(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Option<TicketAssignmentTrackingProjection>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, assignment_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn current(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingProjection>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_current_ticket_assignment_tracking(conn, workspace_id, ticket_id)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn history(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> Result<Vec<TicketAssignmentTrackingHistoryEntry>> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking_history(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn history_page(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
after_event_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<ticket::TicketAssignmentTrackingHistoryPage> {
|
||||
store.with_conn(|conn| {
|
||||
ticket::sqlite_ticket_assignment_tracking_history_page(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
after_event_id,
|
||||
limit,
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
ticket::TicketError::Conflict(message) => Error::InvalidInput(message),
|
||||
other => Error::Store(other.to_string()),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn assignments_page(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
after_assignment_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<ticket::TicketAssignmentsPage> {
|
||||
store.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::sqlite_ticket_assignments_page(
|
||||
&tx,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
after_assignment_id,
|
||||
limit,
|
||||
)
|
||||
.map_err(|e| Error::InvalidInput(e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn answer(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
input: &ticket::TicketAssignmentAnswer,
|
||||
source: &TicketOrchestratorAssignmentSourceContext,
|
||||
actor: &str,
|
||||
) -> Result<ticket::TicketAssignmentAnswerReceipt> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
require_assignment_source(&tx,workspace_id,source)?;
|
||||
// Replay retains its saved result after assignment replacement; new input
|
||||
// must still be admitted by the exact current operational delegation.
|
||||
let replay: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2)",params![workspace_id,input.operation_key],|r| r.get(0))?;
|
||||
if !replay { assignment_delivery::require_current(&tx,workspace_id,ticket_id,&input.assignment_id)?; }
|
||||
let source = serde_json::to_value(source).map_err(|e| Error::Store(e.to_string()))?;
|
||||
let result = ticket::sqlite_answer_ticket_assignment_in_transaction(&tx,workspace_id,ticket_id,input,&source,actor).map_err(|e| Error::TicketAssignmentConflict(e.to_string()))?;
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,12 +98,30 @@ pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>(
|
||||
store: &S,
|
||||
pub async fn resolve_request_actor(
|
||||
store: std::sync::Arc<dyn ControlPlaneStore>,
|
||||
headers: &HeaderMap,
|
||||
cookie_name: &str,
|
||||
) -> Result<Option<RequestActor>> {
|
||||
if let Some(token) = parse_bearer(headers) {
|
||||
let bearer = parse_bearer(headers);
|
||||
let session = parse_cookie(headers, cookie_name);
|
||||
if bearer.is_none() && session.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || resolve_request_actor_sync(store.as_ref(), bearer, session))
|
||||
.await
|
||||
}
|
||||
|
||||
// One owned synchronous stage includes token usage marking and the user read.
|
||||
// Do not call async store wrappers here: they use the same executor themselves.
|
||||
fn resolve_request_actor_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<Option<RequestActor>> {
|
||||
if let Some(token) = bearer {
|
||||
let token_hash = token_hash(&token);
|
||||
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
||||
if api_token
|
||||
@@ -118,7 +136,7 @@ pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(session_token) = parse_cookie(headers, cookie_name) {
|
||||
if let Some(session_token) = session {
|
||||
if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? {
|
||||
if is_expired(&session.expires_at) {
|
||||
return Ok(None);
|
||||
@@ -182,6 +200,182 @@ pub fn auth_error(code: &str, message: &str) -> Error {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{
|
||||
AccountRecord, ApiTokenRecord, BrowserSessionRecord, SqliteWorkspaceStore, UserRecord,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn seeded_store() -> Arc<SqliteWorkspaceStore> {
|
||||
let executor = crate::db_executor::DbExecutor::new(crate::db_executor::DbExecutorLimits {
|
||||
workers: std::num::NonZeroUsize::new(1).unwrap(),
|
||||
queued: std::num::NonZeroUsize::new(1).unwrap(),
|
||||
})
|
||||
.unwrap();
|
||||
let store = Arc::new(
|
||||
SqliteWorkspaceStore::in_memory_for_test()
|
||||
.unwrap()
|
||||
.with_db_executor(executor),
|
||||
);
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "account-auth".into(),
|
||||
kind: "user".into(),
|
||||
handle: "auth".into(),
|
||||
display_name: "Auth".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.upsert_user(&UserRecord {
|
||||
user_id: "user-auth".into(),
|
||||
account_id: "account-auth".into(),
|
||||
handle: "auth".into(),
|
||||
display_name: "Auth".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: "session-auth".into(),
|
||||
token_hash: token_hash("session-secret"),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: "9999-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_token_auth_marks_usage_and_takes_precedence_over_cookie() {
|
||||
let store = seeded_store();
|
||||
let hash = token_hash("api-secret");
|
||||
store
|
||||
.create_api_token(&ApiTokenRecord {
|
||||
token_id: "token-auth".into(),
|
||||
token_hash: hash.clone(),
|
||||
user_id: "user-auth".into(),
|
||||
label: "Auth".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: None,
|
||||
revoked_at: None,
|
||||
last_used_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::AUTHORIZATION, "Bearer api-secret".parse().unwrap());
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
let actor = resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(actor.user_id, "user-auth");
|
||||
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
|
||||
assert!(
|
||||
store
|
||||
.resolve_api_token(&hash)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_api_token_does_not_fall_back_to_valid_browser_session() {
|
||||
let store = seeded_store();
|
||||
let hash = token_hash("expired-secret");
|
||||
store
|
||||
.create_api_token(&ApiTokenRecord {
|
||||
token_id: "token-auth".into(),
|
||||
token_hash: hash.clone(),
|
||||
user_id: "user-auth".into(),
|
||||
label: "Auth".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: Some("2000-01-01T00:00:00Z".into()),
|
||||
revoked_at: None,
|
||||
last_used_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
"Bearer expired-secret".parse().unwrap(),
|
||||
);
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
assert!(
|
||||
resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_api_token(&hash)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_bearer_can_fall_back_to_valid_browser_session() {
|
||||
let store = seeded_store();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
"Bearer unknown-secret".parse().unwrap(),
|
||||
);
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
let actor = resolve_request_actor(store, &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(actor.user_id, "user-auth");
|
||||
assert_eq!(actor.auth_method, ActorAuthMethod::BrowserSession);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saturated_actor_lookup_returns_capacity_error_not_anonymous_actor() {
|
||||
let store = seeded_store();
|
||||
let executor = store.db_executor();
|
||||
let (started, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel();
|
||||
let blocker_executor = executor.clone();
|
||||
let blocker = tokio::spawn(async move {
|
||||
blocker_executor
|
||||
.run(move || -> Result<()> {
|
||||
let _ = started.send(());
|
||||
release_rx.recv().unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), started_rx)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut queued = Box::pin(executor.run(|| -> Result<()> { Ok(()) }));
|
||||
assert!(futures::poll!(queued.as_mut()).is_pending());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
resolve_request_actor(store, &headers, "session"),
|
||||
)
|
||||
.await;
|
||||
release.send(()).unwrap();
|
||||
blocker.await.unwrap().unwrap();
|
||||
queued.await.unwrap();
|
||||
assert!(matches!(
|
||||
result.expect("capacity rejection must not wait for queued SQL"),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_code_uses_the_uuid_random_tail() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Projects Workspace Tickets, Objectives, and Memory from domain records to API resources.
|
||||
//!
|
||||
//! Normal Backend reads use Workspace-scoped SQLite/domain authority, not a Ticket backend
|
||||
//! chosen from Worker cwd or legacy repository file layouts. The ticket crate owns Ticket state,
|
||||
//! relations, and content; the Workspace store owns assignments and Runtime Worker links.
|
||||
//! chosen from Worker cwd or legacy repository file layouts. The ticket crate owns case state,
|
||||
//! content, relations and delegation/handling records; Workspace composes those schemas and
|
||||
//! owns operational Worker links, work releases and startup/delivery fences.
|
||||
//! Builds bounded detail, thread, relation, assignment, and evidence projections, not full
|
||||
//! execution sessions as substitute records for domain decisions.
|
||||
//!
|
||||
@@ -10,7 +11,10 @@
|
||||
//! Evidence filters use current projections excluding closed MRs: commit requires a settled source;
|
||||
//! approved_review requires approval tied to the current Ticket content digest and all target MR
|
||||
//! sources/results. Merged MRs use stored integration evidence, so provider outages alone do not
|
||||
//! invalidate approval.
|
||||
//! invalidate approval. Evidence is a read-side attestation, never a case-close or handling gate.
|
||||
//! An MRless Ticket has no applicable MR evidence, not missing review merely because it is closed.
|
||||
//! Evidence predicates use the full projection after observation; bounded candidate scans can
|
||||
//! return an empty page with more candidates. Continue its cursor rather than assuming exhaustion.
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
@@ -21,11 +25,11 @@ use merge_request::{
|
||||
MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision,
|
||||
};
|
||||
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue};
|
||||
use rusqlite::{OptionalExtension, params, params_from_iter, types::Value as SqlValue};
|
||||
|
||||
use ticket::{
|
||||
SqliteTicketBackend, SqliteTicketListCursor, SqliteTicketListItem, SqliteTicketListPageQuery,
|
||||
TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkflowState, TicketWorkspaceActionPriority,
|
||||
TicketBackend, TicketEvent, TicketIdOrSlug, TicketState, TicketWorkspaceActionPriority,
|
||||
project_ticket_workspace_item,
|
||||
};
|
||||
|
||||
@@ -252,6 +256,20 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
}
|
||||
|
||||
/// Assignment self-report enrichment is a separate projection so retained
|
||||
/// responsibility and existing Ticket constructors stay distinct from status.
|
||||
pub fn ticket_assignment_tracking(
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<Vec<ticket::TicketAssignmentTrackingProjection>> {
|
||||
let ticket = self
|
||||
.ticket_backend
|
||||
.show(TicketIdOrSlug::from(reference))
|
||||
.map_err(Error::from)?;
|
||||
self.store
|
||||
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)
|
||||
}
|
||||
|
||||
pub fn with_source_commit_resolver(
|
||||
mut self,
|
||||
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
|
||||
@@ -296,7 +314,7 @@ impl SqliteWorkspaceAuthority {
|
||||
id: ticket.id,
|
||||
resource_key: ticket.resource_key,
|
||||
title: ticket.title,
|
||||
state: ticket.state,
|
||||
state: ticket.state.as_str().into(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let resources = self
|
||||
@@ -401,17 +419,82 @@ impl SqliteWorkspaceAuthority {
|
||||
let states = query
|
||||
.states
|
||||
.iter()
|
||||
.map(|state| bind(SqlValue::Text(state.clone())))
|
||||
.map(|state| bind(SqlValue::Text(state.as_str().into())))
|
||||
.collect::<Vec<_>>();
|
||||
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
|
||||
}
|
||||
if let Some(reason) = &query.closure_reason {
|
||||
let encoded =
|
||||
serde_json::to_string(reason).map_err(|e| Error::InvalidInput(e.to_string()))?;
|
||||
let value = bind(SqlValue::Text(encoded));
|
||||
predicates.push(format!("t.workflow_state='closed' AND EXISTS (SELECT 1 FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id AND json_extract(h.judgment_json,'$.action.reason')=json_extract({value},'$'))"));
|
||||
}
|
||||
if query.assigned_principal.is_some() || query.handling_status.is_some() {
|
||||
let mut assignment = vec![
|
||||
"a.workspace_id=t.workspace_id".to_string(),
|
||||
"a.ticket_id=t.ticket_id".to_string(),
|
||||
];
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
let value = bind(SqlValue::Text(agent_key.clone()));
|
||||
assignment.push(format!(
|
||||
"a.principal_kind='workspace_agent' AND a.principal_id={value}"
|
||||
));
|
||||
}
|
||||
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => {
|
||||
let value = bind(SqlValue::Text(account_id.clone()));
|
||||
assignment.push(format!(
|
||||
"a.principal_kind='user' AND a.principal_id={value}"
|
||||
));
|
||||
}
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
let runtime = bind(SqlValue::Text(runtime_id.clone()));
|
||||
let worker = bind(SqlValue::Text(worker_id.clone()));
|
||||
assignment.push(format!("a.principal_kind='worker' AND a.runtime_id={runtime} AND a.worker_id={worker}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(status) = &query.handling_status {
|
||||
let value = match status {
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting => "waiting",
|
||||
ticket::TicketAssignmentTrackingStatus::Inprogress => "inprogress",
|
||||
ticket::TicketAssignmentTrackingStatus::Done => "done",
|
||||
};
|
||||
let value = bind(SqlValue::Text(value.into()));
|
||||
assignment.push(format!(
|
||||
"a.role='orchestrator' AND json_extract(a.tracking_json,'$.status')={value}"
|
||||
));
|
||||
}
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id WHERE {})",assignment.join(" AND ")));
|
||||
}
|
||||
for id in &query.tag_ids {
|
||||
let id = bind(SqlValue::Text(id.clone()));
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)"));
|
||||
}
|
||||
for (ids, negate) in [(&query.tag_ids_any, false), (&query.tag_ids_none, true)] {
|
||||
if !ids.is_empty() {
|
||||
let ids = ids
|
||||
.iter()
|
||||
.map(|id| bind(SqlValue::Text(id.clone())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let not = if negate { "NOT " } else { "" };
|
||||
predicates.push(format!("{not}EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id IN ({ids}) AND tag.deleted=0)"));
|
||||
}
|
||||
}
|
||||
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
|
||||
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
|
||||
predicates.push(format!(
|
||||
"(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS (
|
||||
SELECT 1 FROM typed_ticket_events e
|
||||
WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
|
||||
AND lower(e.body) LIKE {p}))",
|
||||
AND lower(e.body) LIKE {p}) OR EXISTS (
|
||||
SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id
|
||||
AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))",
|
||||
p = pattern
|
||||
));
|
||||
}
|
||||
@@ -452,14 +535,7 @@ impl SqliteWorkspaceAuthority {
|
||||
WHERE relation.workspace_id=t.workspace_id
|
||||
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
|
||||
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
|
||||
AND blocker.workflow_state NOT IN ('done','closed'))";
|
||||
let active_blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation
|
||||
JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id
|
||||
AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END
|
||||
WHERE relation.workspace_id=t.workspace_id
|
||||
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
|
||||
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
|
||||
AND blocker.workflow_state NOT IN ('queued','inprogress','done','closed'))";
|
||||
AND blocker.workflow_state = 'open')";
|
||||
if !query.event_kinds.is_empty() {
|
||||
let event_kinds = query
|
||||
.event_kinds
|
||||
@@ -473,17 +549,13 @@ impl SqliteWorkspaceAuthority {
|
||||
// a primary-MR/latest-request or implementation-report heuristic loses candidates.
|
||||
for attention in &query.attention {
|
||||
match attention.as_str() {
|
||||
"done_not_closed" => predicates.push("t.workflow_state='done'".into()),
|
||||
"blocked" => predicates.push(blocker.into()),
|
||||
"unblocked" => predicates.push(format!("NOT {blocker}")),
|
||||
"ready" => predicates.push(format!("t.workflow_state='ready' AND NOT {blocker}")),
|
||||
_ => {} // live evidence is evaluated after observation
|
||||
}
|
||||
}
|
||||
let rank_expression = match sort {
|
||||
TicketQuerySort::Priority => format!(
|
||||
"CASE WHEN t.workflow_state='ready' AND NOT {active_blocker} THEN 0 WHEN t.workflow_state IN ('queued','inprogress') THEN 1 ELSE 2 END"
|
||||
),
|
||||
TicketQuerySort::Priority => "0".to_string(),
|
||||
TicketQuerySort::Relevance => {
|
||||
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
|
||||
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
|
||||
@@ -779,12 +851,12 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
// Responsibility remains visible after work ends; action eligibility must
|
||||
// use only active unfinished work, including after a Ticket is reopened.
|
||||
// Work start eligibility uses unfinished work. Delegation authoring and
|
||||
// clearing use the retained current pointer, including after close.
|
||||
let active_role_assignments = self
|
||||
.store
|
||||
.list_active_ticket_role_assignments(&self.workspace_id, id)?;
|
||||
let has_orchestrator = active_role_assignments
|
||||
let has_orchestrator = role_assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
|
||||
let has_worker = active_role_assignments
|
||||
@@ -807,7 +879,6 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let write_repository_keys = ticket_write_target_repository_keys(&targets);
|
||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||
let mut assignment_diagnostics = Vec::new();
|
||||
if let Some(legacy_assignee) = ticket
|
||||
.meta
|
||||
@@ -821,25 +892,20 @@ impl SqliteWorkspaceAuthority {
|
||||
}
|
||||
let action_blockers = Vec::new();
|
||||
let action_eligibility = TicketActionEligibility {
|
||||
can_assign_orchestrator: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||
) && !has_orchestrator
|
||||
&& !has_worker,
|
||||
can_unassign_orchestrator: has_orchestrator
|
||||
&& matches!(
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||
),
|
||||
can_queue: true,
|
||||
can_start_manual_worker: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning
|
||||
| TicketWorkflowState::Ready
|
||||
| TicketWorkflowState::Queued
|
||||
| TicketWorkflowState::InProgress
|
||||
) && !has_worker
|
||||
&& !has_blockers,
|
||||
// Case openness is not readiness. These are explicit operations;
|
||||
// occupancy remains an independent resource constraint.
|
||||
// Delegation neither occupies nor transfers existing Worker work.
|
||||
// Start eligibility below separately retains unfinished-work guards.
|
||||
// A fresh operation explicitly delegates/reissues even to the same
|
||||
// principal. Expected-assignment CAS (when supplied) and source
|
||||
// authority are checked by the mutation, not by Ticket phase.
|
||||
can_assign_orchestrator: true,
|
||||
can_unassign_orchestrator: has_orchestrator,
|
||||
can_queue: ticket.meta.workflow_state == TicketState::Open,
|
||||
// Unfinished dependencies remain relation diagnostics, not a blanket
|
||||
// start refusal. Backend start/assign still validates authorization,
|
||||
// resource grants, reservations and actual unfinished work.
|
||||
can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open && !has_worker,
|
||||
queue_tickets: dependency_check.queue_tickets.clone(),
|
||||
blockers: action_blockers,
|
||||
};
|
||||
@@ -926,20 +992,29 @@ impl SqliteWorkspaceAuthority {
|
||||
&blocker.blocking_ticket,
|
||||
)?;
|
||||
}
|
||||
let assignment_tracking = self
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?;
|
||||
Ok(TicketDetail {
|
||||
assignment_tracking,
|
||||
id: ticket.meta.id,
|
||||
resource_key,
|
||||
title: ticket.meta.title,
|
||||
state: ticket.meta.workflow_state.as_str().to_string(),
|
||||
state: ticket.meta.workflow_state.into(),
|
||||
readiness: ticket.meta.readiness,
|
||||
priority: ticket.meta.priority,
|
||||
created_at: ticket.meta.created_at,
|
||||
updated_at: ticket.meta.updated_at,
|
||||
content_digest,
|
||||
closure_operation_key: ticket.closure_operation_key.clone(),
|
||||
current_closure: ticket.current_closure.clone(),
|
||||
closure_history: ticket.case_history.clone(),
|
||||
legacy_handoff: ticket.legacy_workflow_snapshot.clone(),
|
||||
queued_by: ticket.meta.queued_by,
|
||||
queued_at: ticket.meta.queued_at,
|
||||
targets,
|
||||
risk_flags: ticket.meta.risk_flags,
|
||||
tags: ticket.tags,
|
||||
body,
|
||||
body_truncated,
|
||||
event_count: ticket.events.len(),
|
||||
@@ -977,6 +1052,44 @@ impl SqliteWorkspaceAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceAuthority {
|
||||
/// Bounded list projection: read only one immutable closure record and the
|
||||
/// migration handoff, never the full body or event history for every row.
|
||||
fn populate_ticket_summary_case_fields(&self, summary: &mut TicketSummary) -> Result<()> {
|
||||
self.store.with_conn(|conn| {
|
||||
summary.closure_operation_key = conn.query_row(
|
||||
"SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![self.workspace_id, summary.id], |row| row.get(0)
|
||||
).optional()?.flatten();
|
||||
if summary.state == server_api::BrowserTicketState::Closed {
|
||||
if let Some(key) = &summary.closure_operation_key {
|
||||
let json: Option<String> = conn.query_row(
|
||||
"SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id=?1 AND ticket_id=?2 AND operation_key=?3",
|
||||
params![self.workspace_id, summary.id, key], |row| row.get(0)
|
||||
).optional()?;
|
||||
if let Some(json) = json {
|
||||
let entry: ticket::TicketCaseHistoryEntry = serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))?;
|
||||
let reason = match entry.action {
|
||||
ticket::TicketCaseAction::Close { reason } | ticket::TicketCaseAction::CorrectClosure { reason } => reason,
|
||||
ticket::TicketCaseAction::Reopen => return Err(Error::Store("Closure key names a reopen receipt".into())),
|
||||
};
|
||||
summary.current_closure = Some(ticket::TicketClosureJudgment {
|
||||
operation_key: entry.operation_key, reason, explanation: entry.explanation,
|
||||
references: entry.references, author: entry.author, at: entry.at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let snapshot: Option<String> = conn.query_row(
|
||||
"SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![self.workspace_id, summary.id], |row| row.get(0)
|
||||
).optional()?;
|
||||
summary.legacy_handoff = snapshot.map(|json| serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))).transpose()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||
let projection = self.ticket_backend.list_workspace_projection(limit)?;
|
||||
@@ -984,24 +1097,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let projection =
|
||||
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
|
||||
let resource_key = item.summary.resource_key.clone().ok_or_else(|| {
|
||||
Error::Store(format!("missing resource key for {}", item.summary.id))
|
||||
})?;
|
||||
Ok::<_, Error>(TicketSummary {
|
||||
resource_key,
|
||||
id: item.summary.id,
|
||||
title: item.summary.title,
|
||||
state: item.summary.workflow_state.as_str().to_string(),
|
||||
priority: item.summary.priority,
|
||||
updated_at: item.summary.updated_at,
|
||||
queued_by: item.summary.queued_by,
|
||||
queued_at: item.summary.queued_at,
|
||||
workspace_action_priority: workspace_action_priority_name(projection.priority)
|
||||
.to_string(),
|
||||
record_source: "sqlite_yoi_ticket".to_string(),
|
||||
})
|
||||
let mut summary = ticket_summary_from_sqlite_item(item)?;
|
||||
self.populate_ticket_summary_case_fields(&mut summary)?;
|
||||
Ok(summary)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(ProjectRecordList {
|
||||
@@ -1019,7 +1117,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
let parsed_states = states
|
||||
.iter()
|
||||
.map(|state| {
|
||||
TicketWorkflowState::parse(state).ok_or_else(|| {
|
||||
TicketState::parse(state).ok_or_else(|| {
|
||||
Error::InvalidRecordId(format!("unsupported ticket state `{state}`"))
|
||||
})
|
||||
})
|
||||
@@ -1043,7 +1141,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(ticket_summary_from_sqlite_item)
|
||||
.map(|item| {
|
||||
let mut summary = ticket_summary_from_sqlite_item(item)?;
|
||||
self.populate_ticket_summary_case_fields(&mut summary)?;
|
||||
Ok(summary)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let next_cursor = page
|
||||
.next
|
||||
@@ -1087,6 +1189,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
let summary = ticket_summary_from_ticket(&authoritative)?;
|
||||
let authoritative_body = authoritative.document.body.clone();
|
||||
let authoritative_events = authoritative.events.clone();
|
||||
let authoritative_case_history = authoritative.case_history.clone();
|
||||
let detail = self.ticket_detail_from_ticket(
|
||||
authoritative,
|
||||
TicketShowRequest {
|
||||
@@ -1100,6 +1203,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
&detail,
|
||||
authoritative_body.as_str(),
|
||||
&authoritative_events,
|
||||
&authoritative_case_history,
|
||||
&query,
|
||||
);
|
||||
let item = ticket_query_item(
|
||||
@@ -1107,6 +1211,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
&detail,
|
||||
authoritative_body.as_str(),
|
||||
&authoritative_events,
|
||||
&authoritative_case_history,
|
||||
&query,
|
||||
);
|
||||
last_scanned_cursor = Some(make_ticket_cursor(&item, sort, &fingerprint));
|
||||
@@ -1959,11 +2064,38 @@ fn ticket_evidence_summary(
|
||||
}
|
||||
|
||||
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
for state in &query.states {
|
||||
if ticket::TicketWorkflowState::parse(state).is_none() {
|
||||
return Err(Error::InvalidRecordId(format!(
|
||||
"unsupported Ticket workflow state `{state}`"
|
||||
)));
|
||||
if let Some(ticket::TicketClosureReason::Other(text)) = &query.closure_reason
|
||||
&& (text.trim().is_empty() || text.len() > 1024 || text.contains('\0'))
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Closure reason Other must be nonblank and at most 1024 bytes without NUL".into(),
|
||||
));
|
||||
}
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
let ids: Vec<&str> = match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id],
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
vec![agent_key]
|
||||
}
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => vec![runtime_id, worker_id],
|
||||
};
|
||||
if ids
|
||||
.iter()
|
||||
.any(|id| id.trim().is_empty() || id.len() > 1024 || id.chars().any(char::is_control))
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"Assigned principal requires bounded stable identities".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] {
|
||||
if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) {
|
||||
return Err(Error::InvalidInput(
|
||||
"tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for evidence in &query.evidence {
|
||||
@@ -1976,25 +2108,6 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
for attention in &query.attention {
|
||||
if !matches!(
|
||||
attention.as_str(),
|
||||
"done_not_closed"
|
||||
| "unresolved_review"
|
||||
| "missing_commit"
|
||||
| "blocked"
|
||||
| "unblocked"
|
||||
| "ready"
|
||||
| "awaiting_review"
|
||||
| "unresolved_changes"
|
||||
| "stale_after_rescope"
|
||||
| "missing_evidence"
|
||||
) {
|
||||
return Err(Error::InvalidRecordId(format!(
|
||||
"unsupported Ticket attention filter `{attention}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(status) = query.review_status.as_deref()
|
||||
&& !matches!(
|
||||
status,
|
||||
@@ -2139,19 +2252,17 @@ fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bo
|
||||
}
|
||||
|
||||
fn ticket_attention_matches(
|
||||
state: &str,
|
||||
_state: &str,
|
||||
evidence: &TicketEvidenceSummary,
|
||||
is_blocked: bool,
|
||||
authoritative_events: &[TicketEvent],
|
||||
attention: &str,
|
||||
) -> bool {
|
||||
match attention {
|
||||
"done_not_closed" => state == "done",
|
||||
"unresolved_review" => evidence.unresolved_request_changes,
|
||||
"missing_commit" => !evidence.has_commit,
|
||||
"blocked" => is_blocked,
|
||||
"unblocked" => !is_blocked,
|
||||
"ready" => state == "ready" && !is_blocked,
|
||||
"awaiting_review" => {
|
||||
evidence.has_review_request && evidence.review_status.as_deref() == Some("pending")
|
||||
}
|
||||
@@ -2168,14 +2279,42 @@ fn ticket_attention_matches(
|
||||
}
|
||||
}
|
||||
|
||||
fn closure_reason_text(action: &ticket::TicketCaseAction) -> Option<String> {
|
||||
let reason = match action {
|
||||
ticket::TicketCaseAction::Close { reason }
|
||||
| ticket::TicketCaseAction::CorrectClosure { reason } => reason,
|
||||
ticket::TicketCaseAction::Reopen => return None,
|
||||
};
|
||||
Some(match reason {
|
||||
ticket::TicketClosureReason::Completed => "completed".into(),
|
||||
ticket::TicketClosureReason::NotPlanned => "not_planned".into(),
|
||||
ticket::TicketClosureReason::Duplicate => "duplicate".into(),
|
||||
ticket::TicketClosureReason::Unknown => "unknown".into(),
|
||||
ticket::TicketClosureReason::Other(text) => format!("other {text}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn ticket_matches_query(
|
||||
summary: &TicketSummary,
|
||||
detail: &TicketDetail,
|
||||
authoritative_body: &str,
|
||||
authoritative_events: &[TicketEvent],
|
||||
authoritative_case_history: &[ticket::TicketCaseHistoryEntry],
|
||||
query: &TicketQueryRequest,
|
||||
) -> bool {
|
||||
if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) {
|
||||
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
|
||||
if !query.tag_ids.iter().all(has_tag)
|
||||
|| (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag))
|
||||
|| query.tag_ids_none.iter().any(has_tag)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !query.states.is_empty()
|
||||
&& !query
|
||||
.states
|
||||
.iter()
|
||||
.any(|state| server_api::BrowserTicketState::from(*state) == summary.state)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
@@ -2223,11 +2362,11 @@ fn ticket_matches_query(
|
||||
}
|
||||
if !query.attention.iter().all(|attention| {
|
||||
ticket_attention_matches(
|
||||
&summary.state,
|
||||
summary.state.as_str(),
|
||||
&detail.evidence,
|
||||
!detail.relations.blockers.is_empty(),
|
||||
authoritative_events,
|
||||
attention,
|
||||
attention.as_str(),
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
@@ -2286,7 +2425,10 @@ fn ticket_matches_query(
|
||||
}
|
||||
query.query.as_ref().is_none_or(|text| {
|
||||
let needle = text.to_lowercase();
|
||||
summary.title.to_lowercase().contains(&needle)
|
||||
authoritative_case_history.iter().any(|entry| {
|
||||
closure_reason_text(&entry.action)
|
||||
.is_some_and(|reason| reason.to_lowercase().contains(&needle))
|
||||
}) || summary.title.to_lowercase().contains(&needle)
|
||||
|| authoritative_body.to_lowercase().contains(&needle)
|
||||
|| authoritative_events
|
||||
.iter()
|
||||
@@ -2299,6 +2441,7 @@ fn ticket_query_item(
|
||||
detail: &TicketDetail,
|
||||
authoritative_body: &str,
|
||||
authoritative_events: &[TicketEvent],
|
||||
authoritative_case_history: &[ticket::TicketCaseHistoryEntry],
|
||||
query: &TicketQueryRequest,
|
||||
) -> TicketQueryItem {
|
||||
let mut matched_fields = Vec::new();
|
||||
@@ -2306,6 +2449,14 @@ fn ticket_query_item(
|
||||
let mut matching_event = None;
|
||||
if let Some(text) = query.query.as_ref() {
|
||||
let needle = text.to_lowercase();
|
||||
if let Some(reason) = authoritative_case_history
|
||||
.iter()
|
||||
.filter_map(|entry| closure_reason_text(&entry.action))
|
||||
.find(|reason| reason.to_lowercase().contains(&needle))
|
||||
{
|
||||
matched_fields.push("closure_reason".into());
|
||||
snippet = Some(reason);
|
||||
}
|
||||
if summary.title.to_lowercase().contains(&needle) {
|
||||
matched_fields.push("title".to_string());
|
||||
snippet = Some(summary.title.clone());
|
||||
@@ -2328,15 +2479,20 @@ fn ticket_query_item(
|
||||
}
|
||||
}
|
||||
TicketQueryItem {
|
||||
assignment_tracking: detail.assignment_tracking.clone(),
|
||||
id: summary.id,
|
||||
resource_key: summary.resource_key,
|
||||
title: summary.title,
|
||||
state: summary.state,
|
||||
readiness: detail.readiness.clone(),
|
||||
tags: detail.tags.clone(),
|
||||
priority: summary.priority,
|
||||
created_at: detail.created_at.clone(),
|
||||
updated_at: summary.updated_at,
|
||||
content_digest: detail.content_digest.clone(),
|
||||
closure_operation_key: detail.closure_operation_key.clone(),
|
||||
current_closure: detail.current_closure.clone(),
|
||||
legacy_handoff: detail.legacy_handoff.clone(),
|
||||
workspace_action_priority: summary.workspace_action_priority,
|
||||
matched_fields,
|
||||
snippet: snippet.map(|value| truncate_body(&value, 512).0),
|
||||
@@ -2689,6 +2845,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary>
|
||||
kind: ticket.meta.kind.clone(),
|
||||
priority: ticket.meta.priority.clone(),
|
||||
labels: ticket.meta.labels.clone(),
|
||||
tags: ticket.tags.clone(),
|
||||
readiness: ticket.meta.readiness.clone(),
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
workflow_state_explicit: ticket.meta.workflow_state_explicit,
|
||||
@@ -2696,10 +2853,14 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary>
|
||||
queued_at: ticket.meta.queued_at.clone(),
|
||||
updated_at: ticket.meta.updated_at.clone(),
|
||||
};
|
||||
ticket_summary_from_sqlite_item(SqliteTicketListItem {
|
||||
let mut summary = ticket_summary_from_sqlite_item(SqliteTicketListItem {
|
||||
summary,
|
||||
relation_blockers: ticket.relations.blockers.clone(),
|
||||
})
|
||||
})?;
|
||||
summary.closure_operation_key = ticket.closure_operation_key.clone();
|
||||
summary.current_closure = ticket.current_closure.clone();
|
||||
summary.legacy_handoff = ticket.legacy_workflow_snapshot.clone();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketSummary> {
|
||||
@@ -2713,13 +2874,17 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketS
|
||||
resource_key,
|
||||
id: item.summary.id,
|
||||
title: item.summary.title,
|
||||
state: item.summary.workflow_state.as_str().to_string(),
|
||||
state: item.summary.workflow_state.into(),
|
||||
priority: item.summary.priority,
|
||||
updated_at: item.summary.updated_at,
|
||||
tags: item.summary.tags,
|
||||
queued_by: item.summary.queued_by,
|
||||
queued_at: item.summary.queued_at,
|
||||
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
||||
record_source: "sqlite_yoi_ticket".to_string(),
|
||||
closure_operation_key: None,
|
||||
current_closure: None,
|
||||
legacy_handoff: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3198,11 +3363,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfinished_dependencies_remain_diagnostic_without_disabling_manual_start() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&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();
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let dependent = backend
|
||||
.create(ticket::NewTicket::new(
|
||||
"Partial investigation without resources",
|
||||
))
|
||||
.unwrap();
|
||||
let prerequisite = backend
|
||||
.create(ticket::NewTicket::new("Unfinished prerequisite"))
|
||||
.unwrap();
|
||||
backend
|
||||
.add_ticket_relation(
|
||||
dependent.id.clone().into(),
|
||||
ticket::NewTicketRelation {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: prerequisite.id.clone(),
|
||||
note: Some("Partial work can proceed".into()),
|
||||
author: Some("owner-account".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
|
||||
let detail = authority.ticket(&dependent.id).unwrap();
|
||||
assert!(detail.targets.is_empty());
|
||||
assert!(detail.action_eligibility.can_start_manual_worker);
|
||||
assert!(detail.action_eligibility.blockers.is_empty());
|
||||
assert_eq!(detail.relations.blockers.len(), 1);
|
||||
assert_eq!(
|
||||
detail.relations.blockers[0].blocking_ticket,
|
||||
prerequisite.id
|
||||
);
|
||||
assert_eq!(detail.relations.outgoing[0].kind, "depends_on");
|
||||
let blocked = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
attention: vec![server_api::TicketAttention::Blocked],
|
||||
..TicketQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(blocked.items.len(), 1);
|
||||
assert_eq!(blocked.items[0].id, dependent.id);
|
||||
assert_eq!(blocked.items[0].unresolved_blocker_count, 1);
|
||||
let prerequisite_detail = authority.ticket(&prerequisite.id).unwrap();
|
||||
assert!(prerequisite_detail.assignments.is_empty());
|
||||
assert_eq!(prerequisite_detail.state.as_str(), "open");
|
||||
rusqlite::Connection::open(&path)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id=?1",
|
||||
[&dependent.id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!authority
|
||||
.ticket(&dependent.id)
|
||||
.unwrap()
|
||||
.action_eligibility
|
||||
.can_start_manual_worker
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_worker_eligibility_accepts_explicit_states_without_reviving_released_work() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "w".into(),
|
||||
@@ -3236,14 +3476,7 @@ mod tests {
|
||||
.create(ticket::NewTicket::new("Explicit work"))
|
||||
.unwrap();
|
||||
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
|
||||
for (state, allowed) in [
|
||||
("planning", true),
|
||||
("ready", true),
|
||||
("queued", true),
|
||||
("inprogress", true),
|
||||
("done", false),
|
||||
("closed", false),
|
||||
] {
|
||||
for (state, allowed) in [("open", true), ("closed", false)] {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state=?1 WHERE ticket_id=?2",
|
||||
rusqlite::params![state, ticket.id],
|
||||
@@ -3260,7 +3493,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
|
||||
"UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id=?1",
|
||||
[&ticket.id],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3316,7 +3549,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
|
||||
"UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id=?1",
|
||||
[&ticket.id],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3342,7 +3575,7 @@ mod tests {
|
||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
|
||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
@@ -3367,11 +3600,11 @@ INSERT INTO typed_tickets (
|
||||
) VALUES
|
||||
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
|
||||
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
|
||||
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'ready', 1),
|
||||
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1),
|
||||
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
|
||||
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'queued', 1),
|
||||
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
|
||||
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
|
||||
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'planning', 1);
|
||||
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
|
||||
|
||||
INSERT INTO typed_ticket_events (
|
||||
workspace_id, ticket_id, event_index, kind, author, at, heading, body
|
||||
@@ -3510,11 +3743,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert_eq!(tickets.record_authority, "workspace-sqlite");
|
||||
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
|
||||
assert_eq!(tickets.items[0].id, "00000000001J2");
|
||||
assert_eq!(tickets.items[0].state, "ready");
|
||||
assert_eq!(
|
||||
tickets.items[0].workspace_action_priority,
|
||||
"ready_for_queue"
|
||||
);
|
||||
assert_eq!(tickets.items[0].state.as_str(), "open");
|
||||
assert_eq!(tickets.items[0].workspace_action_priority, "background");
|
||||
let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap();
|
||||
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||
|
||||
@@ -3530,9 +3760,9 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
let ticket_query = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
query: Some("Deep Ticket marker".to_string()),
|
||||
states: vec!["ready".to_string()],
|
||||
states: vec![ticket::TicketState::Open],
|
||||
linked_objective_id: Some("00000000001J3".to_string()),
|
||||
attention: vec!["missing_commit".to_string()],
|
||||
attention: vec![server_api::TicketAttention::MissingCommit],
|
||||
limit: Some(1),
|
||||
..TicketQueryRequest::default()
|
||||
})
|
||||
@@ -3631,7 +3861,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert_eq!(incoming_relation.items[0].id, "00000000001J5");
|
||||
let summary_page = authority
|
||||
.list_ticket_page(TicketListProjectionRequest {
|
||||
states: vec!["planning".to_string(), "ready".to_string()],
|
||||
states: vec!["open".to_string()],
|
||||
limit: Some(1),
|
||||
cursor: None,
|
||||
})
|
||||
@@ -3639,7 +3869,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert_eq!(summary_page.items.len(), 1);
|
||||
assert!(summary_page.page.has_more);
|
||||
let mismatched_summary_cursor = authority.list_ticket_page(TicketListProjectionRequest {
|
||||
states: vec!["done".to_string()],
|
||||
states: vec!["closed".to_string()],
|
||||
limit: Some(1),
|
||||
cursor: summary_page.page.next_cursor,
|
||||
});
|
||||
@@ -3708,7 +3938,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
let objective = authority.objective("00000000001J3").unwrap();
|
||||
assert!(objective.body.contains("Objective body"));
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "open");
|
||||
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
|
||||
let objective_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
@@ -3777,7 +4007,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
async fn objective_mutations_write_sqlite_records_and_audit_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
@@ -3797,8 +4027,8 @@ INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES
|
||||
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
|
||||
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
|
||||
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
|
||||
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
|
||||
INSERT INTO workspace_resource_keys (
|
||||
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
|
||||
) VALUES
|
||||
|
||||
@@ -795,7 +795,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn open_store() -> SqliteWorkspaceStore {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
store
|
||||
}
|
||||
@@ -1454,7 +1454,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_candidate_is_never_persisted() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
||||
@@ -1478,7 +1478,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_candidate_commits_snapshot_content_and_provenance_atomically() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let committed = store
|
||||
@@ -1496,7 +1496,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_cas_cannot_overwrite_newer_tree() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let request = commit_request(¤t, vec![update_main(¤t, "{ answer = 42; }")]);
|
||||
@@ -1514,7 +1514,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_content_remains_retrievable_after_later_commit() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.upsert_workspace(&workspace()).await.unwrap();
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let first = store
|
||||
@@ -1772,7 +1772,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn migration_creates_config_authority_tables() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for table in [
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
//! Bounded execution of whole synchronous DB operations, independent of Tokio.
|
||||
//!
|
||||
//! Accepted jobs belong to the workers, not their async waiters. Cancelling a
|
||||
//! waiter does not cancel or retry SQL, release capacity, or release its lease.
|
||||
//! Ownership lasts through discarded-result cleanup and ordered local publication.
|
||||
//! Synchronous post-commit child submissions inherit the lifecycle source; detached
|
||||
//! Tokio tasks do not. Panics report unknown outcome even after commit, never retry.
|
||||
//!
|
||||
//! Admission is nonblocking and bounds actual jobs as well as queued jobs. Rejection
|
||||
//! means not accepted; HTTP callers classify capacity/unavailability as 503, not
|
||||
//! authentication failure or domain conflict. Production stores share one owner;
|
||||
//! fixtures inject isolated owners whose clones share limits, without global settings.
|
||||
//! Stop producers before draining and destroying resources; a drain is not queue closure.
|
||||
//!
|
||||
//! Move the whole connection/mutex/transaction/commit/publication operation into one
|
||||
//! owned closure. Never split source checks, receipt comparison, CAS, audit, or results
|
||||
//! around awaits or add a read pool. Runtime/network waits belong on the independent
|
||||
//! Host lane, outside SQL transactions, so mandatory DB callbacks can still finish.
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
future::Future,
|
||||
num::NonZeroUsize,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
};
|
||||
use tokio::sync::{Notify, oneshot};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The default is one FIFO worker and at most 32 waiting operations.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DbExecutorLimits {
|
||||
pub workers: NonZeroUsize,
|
||||
pub queued: NonZeroUsize,
|
||||
}
|
||||
|
||||
impl Default for DbExecutorLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workers: NonZeroUsize::new(1).unwrap(),
|
||||
queued: NonZeroUsize::new(32).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request can retain its lifecycle authority until actual SQL finishes.
|
||||
/// Retention happens at submission, while the caller's task-local scope exists.
|
||||
pub(crate) trait DbOperationLifetime: Send + Sync {
|
||||
fn retain(&self) -> Box<dyn Send>;
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static OPERATION_LIFETIME: Arc<dyn DbOperationLifetime>;
|
||||
}
|
||||
|
||||
pub(crate) async fn with_operation_lifetime<F: Future>(
|
||||
lifetime: Arc<dyn DbOperationLifetime>,
|
||||
future: F,
|
||||
) -> F::Output {
|
||||
OPERATION_LIFETIME.scope(lifetime, future).await
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Accepted {
|
||||
last_job_id: u64,
|
||||
unfinished: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Tracking {
|
||||
accepted: Mutex<Accepted>,
|
||||
finished: Notify,
|
||||
}
|
||||
|
||||
struct Completion {
|
||||
tracking: Arc<Tracking>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Drop for Completion {
|
||||
fn drop(&mut self) {
|
||||
self.tracking
|
||||
.accepted
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unfinished
|
||||
.remove(&self.id);
|
||||
self.tracking.finished.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
operation: Box<dyn FnOnce() + Send>,
|
||||
// Field drop order releases authority before completion becomes observable.
|
||||
_lifetime: Option<Box<dyn Send>>,
|
||||
lifetime_source: Option<Arc<dyn DbOperationLifetime>>,
|
||||
_completion: Completion,
|
||||
runtime: Option<tokio::runtime::Handle>,
|
||||
diagnostics: tracing::Dispatch,
|
||||
span: tracing::Span,
|
||||
}
|
||||
|
||||
fn report_unknown(accepted_job_id: u64, phase: &'static str) {
|
||||
tracing::error!(
|
||||
code = "db_operation_outcome_unknown",
|
||||
accepted_job_id,
|
||||
phase,
|
||||
error = %Error::DbOperationOutcomeUnknown,
|
||||
"DB job panicked; outcome unknown, operation will not be retried"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clones share the queue and actual-job budget. Workers retain only the
|
||||
/// receiver, so dropping the last sender closes the queue; accepted jobs finish
|
||||
/// before the dedicated threads exit. No thread is created per store open.
|
||||
#[derive(Clone)]
|
||||
pub struct DbExecutor {
|
||||
sender: Option<mpsc::SyncSender<Job>>,
|
||||
tracking: Arc<Tracking>,
|
||||
max_jobs: usize,
|
||||
}
|
||||
|
||||
impl DbExecutor {
|
||||
pub fn new(limits: DbExecutorLimits) -> Result<Self> {
|
||||
let max_jobs = limits
|
||||
.workers
|
||||
.get()
|
||||
.checked_add(limits.queued.get())
|
||||
.ok_or_else(|| Error::InvalidInput("DB executor limits overflow".into()))?;
|
||||
let (sender, receiver) = mpsc::sync_channel::<Job>(limits.queued.get());
|
||||
let receiver = Arc::new(Mutex::new(receiver));
|
||||
for index in 0..limits.workers.get() {
|
||||
let receiver = receiver.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("workspace-db-{index}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let next = receiver.lock().unwrap().recv();
|
||||
let Ok(job) = next else { break };
|
||||
let Job {
|
||||
operation,
|
||||
_lifetime: lifetime,
|
||||
lifetime_source,
|
||||
_completion: completion,
|
||||
runtime,
|
||||
diagnostics,
|
||||
span,
|
||||
} = job;
|
||||
// Keep diagnostics with their submitting subscriber as
|
||||
// well as the runtime; no process-global override.
|
||||
let _diagnostics = tracing::dispatcher::set_default(&diagnostics);
|
||||
let _span = span.enter();
|
||||
// Preserve the submitting runtime for synchronous Host
|
||||
// post-commit hooks, including reply/lease destructors.
|
||||
// enter() supplies context only: never drive a future
|
||||
// or await/nest an executor submission in a transaction.
|
||||
let _entered = runtime.as_ref().map(tokio::runtime::Handle::enter);
|
||||
// A cancelled waiter's discarded result may itself have
|
||||
// a destructor. Keep both the lease and actual-job slot
|
||||
// until that cleanup finishes, even if it panics.
|
||||
let execute = || {
|
||||
if catch_unwind(AssertUnwindSafe(operation)).is_err() {
|
||||
report_unknown(completion.id, "reply_cleanup");
|
||||
}
|
||||
if catch_unwind(AssertUnwindSafe(|| drop(lifetime))).is_err() {
|
||||
report_unknown(completion.id, "lifetime_cleanup");
|
||||
}
|
||||
};
|
||||
// Synchronous post-commit continuations can admit another
|
||||
// bounded job. Transfer the original lifecycle source as
|
||||
// well, so its authority survives the parent job/waiter.
|
||||
// This does not inject context into detached Tokio tasks.
|
||||
if let Some(source) = lifetime_source {
|
||||
OPERATION_LIFETIME.sync_scope(source, execute);
|
||||
} else {
|
||||
execute();
|
||||
}
|
||||
drop(completion);
|
||||
}
|
||||
})
|
||||
.map_err(|_| Error::DbExecutorUnavailable)?;
|
||||
}
|
||||
Ok(Self {
|
||||
sender: Some(sender),
|
||||
tracking: Arc::default(),
|
||||
max_jobs,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shared() -> Self {
|
||||
static SHARED: OnceLock<DbExecutor> = OnceLock::new();
|
||||
SHARED
|
||||
.get_or_init(|| {
|
||||
Self::new(DbExecutorLimits::default()).unwrap_or_else(|_| Self {
|
||||
sender: None,
|
||||
tracking: Arc::default(),
|
||||
max_jobs: 0,
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Submit without waiting for queue capacity. The closure must contain the
|
||||
/// entire synchronous operation/transaction, with no nested async runtime.
|
||||
/// The submitting Tokio handle, when present, is entered for synchronous
|
||||
/// post-commit hooks and worker cleanup; it is never used to block_on SQL.
|
||||
pub async fn run<T, E>(
|
||||
&self,
|
||||
operation: impl FnOnce() -> std::result::Result<T, E> + Send + 'static,
|
||||
) -> std::result::Result<T, E>
|
||||
where
|
||||
T: Send + 'static,
|
||||
E: From<Error> + Send + 'static,
|
||||
{
|
||||
self.submit(operation)?
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::DbOperationOutcomeUnknown.into()))
|
||||
}
|
||||
|
||||
/// Admit a fire-and-forget operation immediately, without spawning a Tokio
|
||||
/// task or waiting for capacity. Err means the operation was not accepted;
|
||||
/// Ok means the worker owns it through execution and cleanup, even after
|
||||
/// the submitting task is cancelled. A panic emits a structured unknown-
|
||||
/// outcome diagnostic, never an automatic retry. drain() tracks these jobs.
|
||||
pub fn dispatch(&self, operation: impl FnOnce() + Send + 'static) -> Result<()> {
|
||||
self.submit(move || {
|
||||
operation();
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Shared synchronous admission: neither dropped receivers nor cancelled
|
||||
/// callers remove accepted work from the queue or the actual-job budget.
|
||||
fn submit<T, E>(
|
||||
&self,
|
||||
operation: impl FnOnce() -> std::result::Result<T, E> + Send + 'static,
|
||||
) -> std::result::Result<oneshot::Receiver<std::result::Result<T, E>>, E>
|
||||
where
|
||||
T: Send + 'static,
|
||||
E: From<Error> + Send + 'static,
|
||||
{
|
||||
let sender = self
|
||||
.sender
|
||||
.as_ref()
|
||||
.ok_or_else(|| E::from(Error::DbExecutorUnavailable))?;
|
||||
let runtime = tokio::runtime::Handle::try_current().ok();
|
||||
let diagnostics = tracing::dispatcher::get_default(Clone::clone);
|
||||
let span = tracing::Span::current();
|
||||
let lifetime_source = OPERATION_LIFETIME.try_with(Clone::clone).ok();
|
||||
let lifetime = lifetime_source.as_ref().map(|source| source.retain());
|
||||
let (reply, result) = oneshot::channel();
|
||||
// Serialize admission and acceptance snapshots. Worker completion cannot
|
||||
// race acceptance, and cancelled waiters never alter this actual-job cap.
|
||||
{
|
||||
let mut accepted = self.tracking.accepted.lock().unwrap();
|
||||
if accepted.unfinished.len() >= self.max_jobs {
|
||||
return Err(Error::DbCapacityExceeded.into());
|
||||
}
|
||||
let id = accepted
|
||||
.last_job_id
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| E::from(Error::DbExecutorUnavailable))?;
|
||||
// Never reuse the identity of a rejected job: its destructor may run
|
||||
// after another submission has acquired the admission lock.
|
||||
accepted.last_job_id = id;
|
||||
let job = Job {
|
||||
operation: Box::new(move || {
|
||||
let outcome = catch_unwind(AssertUnwindSafe(operation)).unwrap_or_else(|_| {
|
||||
report_unknown(id, "operation");
|
||||
Err(Error::DbOperationOutcomeUnknown.into())
|
||||
});
|
||||
let _ = reply.send(outcome);
|
||||
}),
|
||||
_lifetime: lifetime,
|
||||
lifetime_source,
|
||||
runtime,
|
||||
diagnostics,
|
||||
span,
|
||||
_completion: Completion {
|
||||
tracking: self.tracking.clone(),
|
||||
id,
|
||||
},
|
||||
};
|
||||
match sender.try_send(job) {
|
||||
Ok(()) => {
|
||||
accepted.unfinished.insert(id);
|
||||
}
|
||||
Err(error) => {
|
||||
// The rejected Job's completion also locks tracking; drop it only
|
||||
// after releasing the admission lock.
|
||||
drop(accepted);
|
||||
return Err(match error {
|
||||
mpsc::TrySendError::Full(_) => Error::DbCapacityExceeded,
|
||||
mpsc::TrySendError::Disconnected(_) => Error::DbExecutorUnavailable,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Wait for every job accepted before this barrier is first polled (not
|
||||
/// later submissions). This bypasses queue capacity and survives cancelled
|
||||
/// job waiters. It does not close the queue or stop new submissions.
|
||||
pub async fn drain(&self) -> Result<()> {
|
||||
if self.sender.is_none() {
|
||||
return Err(Error::DbExecutorUnavailable);
|
||||
}
|
||||
let through = self.tracking.accepted.lock().unwrap().last_job_id;
|
||||
loop {
|
||||
let notified = self.tracking.finished.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if self
|
||||
.tracking
|
||||
.accepted
|
||||
.lock()
|
||||
.unwrap()
|
||||
.unfinished
|
||||
.first()
|
||||
.is_none_or(|id| *id > through)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::{Connection, TransactionBehavior, ffi};
|
||||
use std::{ffi::c_void, time::Duration};
|
||||
|
||||
fn executor(queued: usize) -> DbExecutor {
|
||||
DbExecutor::new(DbExecutorLimits {
|
||||
workers: NonZeroUsize::new(1).unwrap(),
|
||||
queued: NonZeroUsize::new(queued).unwrap(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn database() -> (tempfile::TempDir, Connection, Connection) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("jobs.sqlite");
|
||||
let owner = Connection::open(&path).unwrap();
|
||||
owner
|
||||
.execute_batch("CREATE TABLE writes (value INTEGER NOT NULL)")
|
||||
.unwrap();
|
||||
let worker = Connection::open(&path).unwrap();
|
||||
(dir, owner, worker)
|
||||
}
|
||||
|
||||
struct BusyGate {
|
||||
waiting: Option<oneshot::Sender<()>>,
|
||||
release: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
// A fixture-owned callback proves SQLite actually reached its lock wait.
|
||||
// Unlike busy_handler's fn pointer API, the SQLite API carries a per-call
|
||||
// context, so tests need neither process-global state nor guessed sleeps.
|
||||
struct BusyRegistration {
|
||||
db: *mut ffi::sqlite3,
|
||||
_gate: Box<BusyGate>,
|
||||
}
|
||||
|
||||
impl BusyRegistration {
|
||||
fn install(conn: &Connection, gate: BusyGate) -> Self {
|
||||
unsafe extern "C" fn wait(context: *mut c_void, _: i32) -> i32 {
|
||||
// The Box remains registered only while this connection and
|
||||
// BusyRegistration are alive, on this same dedicated worker.
|
||||
let gate = unsafe { &mut *context.cast::<BusyGate>() };
|
||||
if let Some(waiting) = gate.waiting.take() {
|
||||
let _ = waiting.send(());
|
||||
if gate.release.recv().is_ok() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
let mut gate = Box::new(gate);
|
||||
// SAFETY: conn owns the handle; registration is dropped before conn.
|
||||
let db = unsafe { conn.handle() };
|
||||
let result = unsafe {
|
||||
ffi::sqlite3_busy_handler(db, Some(wait), (&mut *gate as *mut BusyGate).cast())
|
||||
};
|
||||
assert_eq!(result, ffi::SQLITE_OK);
|
||||
Self { db, _gate: gate }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BusyRegistration {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: unregister before releasing the callback's boxed context.
|
||||
unsafe {
|
||||
ffi::sqlite3_busy_handler(self.db, None, std::ptr::null_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn observed<T>(future: impl Future<Output = T>) -> T {
|
||||
tokio::time::timeout(Duration::from_secs(5), future)
|
||||
.await
|
||||
.expect("DB job did not reach the expected observable state")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn sqlite_lock_wait_preserves_timer_and_independent_control_progress() {
|
||||
let (_dir, mut owner, mut worker) = database();
|
||||
let lock = owner
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
let executor = executor(1);
|
||||
let (waiting, blocked) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let mut write = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _busy = BusyRegistration::install(
|
||||
&worker,
|
||||
BusyGate {
|
||||
waiting: Some(waiting),
|
||||
release: permit,
|
||||
},
|
||||
);
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut write).is_pending());
|
||||
observed(blocked).await.unwrap();
|
||||
let control = tokio::spawn(async { 42 });
|
||||
assert_eq!(observed(control).await.unwrap(), 42);
|
||||
// The timer itself is the contract under test, not synchronization.
|
||||
observed(tokio::time::sleep(Duration::from_millis(1))).await;
|
||||
assert!(futures::poll!(&mut write).is_pending());
|
||||
lock.rollback().unwrap();
|
||||
release.send(()).unwrap();
|
||||
observed(write).await.unwrap();
|
||||
executor.drain().await.unwrap();
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
struct Lease(Arc<std::sync::atomic::AtomicUsize>);
|
||||
struct Retained(Arc<std::sync::atomic::AtomicUsize>);
|
||||
impl DbOperationLifetime for Lease {
|
||||
fn retain(&self) -> Box<dyn Send> {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Box::new(Retained(self.0.clone()))
|
||||
}
|
||||
}
|
||||
impl Drop for Retained {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_queued_mutation_retains_capacity_and_lease_until_sql_commits() {
|
||||
let (_dir, mut owner, mut worker) = database();
|
||||
let lock = owner
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
let executor = executor(1);
|
||||
let (waiting, blocked) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let mut running = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _busy = BusyRegistration::install(
|
||||
&worker,
|
||||
BusyGate {
|
||||
waiting: Some(waiting),
|
||||
release: permit,
|
||||
},
|
||||
);
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut running).is_pending());
|
||||
observed(blocked).await.unwrap();
|
||||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let lease: Arc<dyn DbOperationLifetime> = Arc::new(Lease(count.clone()));
|
||||
let mut queued_conn = Connection::open(_dir.path().join("jobs.sqlite")).unwrap();
|
||||
let mut queued = Box::pin(with_operation_lifetime(
|
||||
lease,
|
||||
executor.run(move || -> Result<()> {
|
||||
let tx = queued_conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
// FIFO is observable in durable SQL, not inferred from waiter order.
|
||||
let first: i64 =
|
||||
tx.query_row("SELECT count(*) FROM writes WHERE value = 1", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(first, 1);
|
||||
tx.execute("INSERT INTO writes VALUES (2)", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}),
|
||||
));
|
||||
assert!(futures::poll!(&mut queued).is_pending());
|
||||
drop(queued);
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert!(matches!(
|
||||
executor.run(|| Ok::<_, Error>(())).await,
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
let mut drained = Box::pin(executor.drain());
|
||||
assert!(futures::poll!(&mut drained).is_pending());
|
||||
lock.rollback().unwrap();
|
||||
release.send(()).unwrap();
|
||||
observed(running).await.unwrap();
|
||||
observed(drained).await.unwrap();
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn post_commit_child_job_retains_admitted_lifetime_after_parent_waiter_and_job_finish() {
|
||||
let (dir, owner, mut parent_conn) = database();
|
||||
let mut child_conn = Connection::open(dir.path().join("jobs.sqlite")).unwrap();
|
||||
let parent = executor(1);
|
||||
let child = executor(1);
|
||||
let child_owner = child.clone();
|
||||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let source: Arc<dyn DbOperationLifetime> = Arc::new(Lease(count.clone()));
|
||||
let (entered, running) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let (child_entered, child_running) = oneshot::channel();
|
||||
let (finish_child, child_permit) = mpsc::channel();
|
||||
let mut operation = Box::pin(with_operation_lifetime(
|
||||
source,
|
||||
parent.run(move || -> Result<()> {
|
||||
let _ = entered.send(());
|
||||
let _ = permit.recv();
|
||||
let tx = parent_conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.commit()?;
|
||||
child_owner.dispatch(move || {
|
||||
let tx = child_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
let committed: i64 = tx
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(committed, 1, "child must see its parent's committed SQL");
|
||||
let _ = child_entered.send(());
|
||||
let _ = child_permit.recv();
|
||||
tx.execute("INSERT INTO writes VALUES (2)", []).unwrap();
|
||||
tx.commit().unwrap();
|
||||
})?;
|
||||
Ok(())
|
||||
}),
|
||||
));
|
||||
assert!(futures::poll!(&mut operation).is_pending());
|
||||
observed(running).await.unwrap();
|
||||
drop(operation);
|
||||
release.send(()).unwrap();
|
||||
observed(child_running).await.unwrap();
|
||||
observed(parent.drain()).await.unwrap();
|
||||
assert_eq!(
|
||||
count.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"accepted continuation lost authority when its parent finished"
|
||||
);
|
||||
let mut drained = Box::pin(child.drain());
|
||||
assert!(futures::poll!(&mut drained).is_pending());
|
||||
finish_child.send(()).unwrap();
|
||||
observed(drained).await.unwrap();
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_transaction_rolls_back_and_releases_actual_job_capacity() {
|
||||
let (_dir, owner, mut worker) = database();
|
||||
let executor = executor(1);
|
||||
let error = executor
|
||||
.run(move || -> Result<()> {
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.execute("INSERT INTO writes VALUES (NULL)", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, Error::Sqlite(_)));
|
||||
executor.drain().await.unwrap();
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panic_after_commit_reports_unknown_without_retry_and_worker_remains_available() {
|
||||
let (_dir, owner, mut worker) = database();
|
||||
let executor = executor(1);
|
||||
let error = executor
|
||||
.run(move || -> Result<()> {
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.commit()?;
|
||||
panic!("lost outcome after commit");
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, Error::DbOperationOutcomeUnknown));
|
||||
executor.drain().await.unwrap();
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(executor.run(|| Ok::<_, Error>(42)).await.unwrap(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panic_before_commit_rolls_back_and_releases_lease_and_capacity() {
|
||||
let (_dir, owner, mut worker) = database();
|
||||
let executor = executor(1);
|
||||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let lease: Arc<dyn DbOperationLifetime> = Arc::new(Lease(count.clone()));
|
||||
let error = with_operation_lifetime(
|
||||
lease,
|
||||
executor.run(move || -> Result<()> {
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
panic!("lost outcome before commit");
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, Error::DbOperationOutcomeUnknown));
|
||||
executor.drain().await.unwrap();
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||
}
|
||||
|
||||
struct DiscardedReplyHook {
|
||||
expected_runtime: tokio::runtime::Id,
|
||||
lease_count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
dropped: Option<oneshot::Sender<(bool, usize)>>,
|
||||
}
|
||||
|
||||
impl Drop for DiscardedReplyHook {
|
||||
fn drop(&mut self) {
|
||||
let runtime =
|
||||
tokio::runtime::Handle::try_current().expect("reply cleanup lost runtime context");
|
||||
let retained = self.lease_count.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let same_runtime = runtime.id() == self.expected_runtime;
|
||||
let dropped = self.dropped.take().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = dropped.send((same_runtime, retained));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_queued_sql_keeps_runtime_context_and_lease_through_post_commit_and_reply_drop()
|
||||
{
|
||||
let (_dir, owner, mut worker) = database();
|
||||
let executor = executor(1);
|
||||
let (started, running) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let mut blocker = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _ = started.send(());
|
||||
let _ = permit.recv();
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut blocker).is_pending());
|
||||
observed(running).await.unwrap();
|
||||
let expected_runtime = tokio::runtime::Handle::current().id();
|
||||
let (hook, published) = oneshot::channel();
|
||||
let (finish, finishing) = mpsc::channel();
|
||||
let (dropped, reply_dropped) = oneshot::channel();
|
||||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let lease: Arc<dyn DbOperationLifetime> = Arc::new(Lease(count.clone()));
|
||||
let operation_count = count.clone();
|
||||
let mut queued = Box::pin(with_operation_lifetime(
|
||||
lease,
|
||||
executor.run(move || -> Result<_> {
|
||||
let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute("INSERT INTO writes VALUES (1)", [])?;
|
||||
tx.commit()?;
|
||||
// Model synchronous Host hooks: publish only after commit, checking
|
||||
// try_current and spawning without driving any future on this worker.
|
||||
let runtime = tokio::runtime::Handle::try_current()
|
||||
.expect("post-commit hook lost runtime context");
|
||||
assert_eq!(runtime.id(), expected_runtime);
|
||||
tokio::spawn(async move {
|
||||
let _ = hook.send(());
|
||||
});
|
||||
let _ = finishing.recv();
|
||||
Ok(DiscardedReplyHook {
|
||||
expected_runtime,
|
||||
lease_count: operation_count,
|
||||
dropped: Some(dropped),
|
||||
})
|
||||
}),
|
||||
));
|
||||
assert!(futures::poll!(&mut queued).is_pending());
|
||||
drop(queued);
|
||||
release.send(()).unwrap();
|
||||
observed(blocker).await.unwrap();
|
||||
observed(published).await.unwrap();
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
let mut drain = Box::pin(executor.drain());
|
||||
assert!(futures::poll!(&mut drain).is_pending());
|
||||
finish.send(()).unwrap();
|
||||
assert_eq!(observed(reply_dropped).await.unwrap(), (true, 1));
|
||||
observed(drain).await.unwrap();
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn multiple_workers_bound_actual_jobs_even_after_all_waiters_are_cancelled() {
|
||||
let executor = DbExecutor::new(DbExecutorLimits {
|
||||
workers: NonZeroUsize::new(2).unwrap(),
|
||||
queued: NonZeroUsize::new(1).unwrap(),
|
||||
})
|
||||
.unwrap();
|
||||
let mut releases = Vec::new();
|
||||
// Observe each worker executing before submitting the next operation,
|
||||
// so queue occupancy and overload are deterministic, not timing guesses.
|
||||
for _ in 0..2 {
|
||||
let (started, running) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let mut operation = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _ = started.send(());
|
||||
let _ = permit.recv();
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut operation).is_pending());
|
||||
observed(running).await.unwrap();
|
||||
drop(operation);
|
||||
releases.push(release);
|
||||
}
|
||||
let (finished, mut queued_finished) = oneshot::channel();
|
||||
let mut queued = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _ = finished.send(());
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut queued).is_pending());
|
||||
drop(queued);
|
||||
let rejected_runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
for _ in 0..8 {
|
||||
let rejected_runs = rejected_runs.clone();
|
||||
assert!(matches!(
|
||||
executor
|
||||
.run(move || {
|
||||
rejected_runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.await,
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
}
|
||||
assert_eq!(rejected_runs.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert!(matches!(
|
||||
queued_finished.try_recv(),
|
||||
Err(oneshot::error::TryRecvError::Empty)
|
||||
));
|
||||
let mut drain = Box::pin(executor.drain());
|
||||
assert!(futures::poll!(&mut drain).is_pending());
|
||||
for release in releases {
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
observed(drain).await.unwrap();
|
||||
observed(queued_finished).await.unwrap();
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UnknownDiagnostic {
|
||||
code: Option<String>,
|
||||
phase: Option<String>,
|
||||
accepted_job_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl tracing::field::Visit for UnknownDiagnostic {
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
match field.name() {
|
||||
"code" => self.code = Some(value.to_owned()),
|
||||
"phase" => self.phase = Some(value.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
||||
if field.name() == "accepted_job_id" {
|
||||
self.accepted_job_id = Some(value);
|
||||
}
|
||||
}
|
||||
fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct DiagnosticCapture(Arc<Mutex<Vec<UnknownDiagnostic>>>);
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for DiagnosticCapture {
|
||||
fn on_event(
|
||||
&self,
|
||||
event: &tracing::Event<'_>,
|
||||
_: tracing_subscriber::layer::Context<'_, S>,
|
||||
) {
|
||||
let mut diagnostic = UnknownDiagnostic::default();
|
||||
event.record(&mut diagnostic);
|
||||
self.0.lock().unwrap().push(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
struct DispatchCleanup {
|
||||
expected_runtime: tokio::runtime::Id,
|
||||
lease_count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
started: Option<oneshot::Sender<(bool, usize)>>,
|
||||
release: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Drop for DispatchCleanup {
|
||||
fn drop(&mut self) {
|
||||
// No panicking assertions during unwind: report observations to the
|
||||
// test, and gate cleanup so drain/lifetime behavior is observable.
|
||||
let same_runtime = tokio::runtime::Handle::try_current()
|
||||
.is_ok_and(|runtime| runtime.id() == self.expected_runtime);
|
||||
let retained = self.lease_count.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(started) = self.started.take() {
|
||||
let _ = started.send((same_runtime, retained));
|
||||
}
|
||||
let _ = self.release.recv();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dispatched_panic_is_bounded_retained_through_cleanup_diagnosed_and_drained_without_retry()
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
let (_dir, owner, mut worker) = database();
|
||||
let executor = executor(1);
|
||||
let (started, running) = oneshot::channel();
|
||||
let (release, permit) = mpsc::channel();
|
||||
let mut blocker = Box::pin(executor.run(move || -> Result<()> {
|
||||
let _ = started.send(());
|
||||
let _ = permit.recv();
|
||||
Ok(())
|
||||
}));
|
||||
assert!(futures::poll!(&mut blocker).is_pending());
|
||||
observed(running).await.unwrap();
|
||||
drop(blocker); // Cancellation does not free its running slot.
|
||||
let expected_runtime = tokio::runtime::Handle::current().id();
|
||||
let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let lease: Arc<dyn DbOperationLifetime> = Arc::new(Lease(count.clone()));
|
||||
let operation_count = count.clone();
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let operation_attempts = attempts.clone();
|
||||
let (cleanup_started, cleaning_up) = oneshot::channel();
|
||||
let (finish, finishing) = mpsc::channel();
|
||||
// Model multiple tracing contexts without setting a global subscriber.
|
||||
// This also prevents tracing's single-subscriber callsite optimization
|
||||
// from caching an unsubscribed parallel test's Interest::never here.
|
||||
let _other_diagnostics = tracing::Dispatch::new(tracing_subscriber::registry());
|
||||
let captured = DiagnosticCapture::default();
|
||||
let diagnostics =
|
||||
tracing::Dispatch::new(tracing_subscriber::registry().with(captured.clone()));
|
||||
with_operation_lifetime(lease, async {
|
||||
tracing::dispatcher::with_default(&diagnostics, || {
|
||||
executor.dispatch(move || {
|
||||
let _cleanup = DispatchCleanup {
|
||||
expected_runtime,
|
||||
lease_count: operation_count,
|
||||
started: Some(cleanup_started),
|
||||
release: finishing,
|
||||
};
|
||||
operation_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let tx = worker
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
tx.execute("INSERT INTO writes VALUES (1)", []).unwrap();
|
||||
tx.commit().unwrap();
|
||||
panic!("dispatched post-commit outcome lost");
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap(); // Dispatch is admitted synchronously; no waiter survives.
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
let rejected_runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let rejected_operation = rejected_runs.clone();
|
||||
assert!(matches!(
|
||||
executor.dispatch(move || {
|
||||
rejected_operation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(observed(cleaning_up).await.unwrap(), (true, 1));
|
||||
assert_eq!(
|
||||
owner
|
||||
.query_row("SELECT count(*) FROM writes", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let mut drain = Box::pin(executor.drain());
|
||||
assert!(futures::poll!(&mut drain).is_pending());
|
||||
finish.send(()).unwrap();
|
||||
observed(drain).await.unwrap();
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(rejected_runs.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
{
|
||||
let events = captured.0.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].code.as_deref(),
|
||||
Some("db_operation_outcome_unknown")
|
||||
);
|
||||
assert_eq!(events[0].phase.as_deref(), Some("operation"));
|
||||
assert!(events[0].accepted_job_id.is_some());
|
||||
}
|
||||
let (completed, completion) = oneshot::channel();
|
||||
executor
|
||||
.dispatch(move || {
|
||||
let _ = completed.send(());
|
||||
})
|
||||
.unwrap();
|
||||
observed(executor.drain()).await.unwrap();
|
||||
observed(completion).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,7 @@ fn content_cutover_reserved_only_releases_resource_but_uncertain_dispatch_keeps_
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
|
||||
};
|
||||
let job = store
|
||||
@@ -491,6 +492,7 @@ fn content_cutover_completed_result_cleanup_and_replay_remain_unchanged() {
|
||||
migrate_content_state_v85_to_v86(&conn).unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(Connection::open(path).unwrap())),
|
||||
};
|
||||
let job = store
|
||||
|
||||
@@ -469,6 +469,7 @@ fn migrated_workdir_request_replays_and_retries_without_accepting_changed_displa
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
};
|
||||
let migrated = store
|
||||
@@ -652,6 +653,7 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
let archived = store
|
||||
@@ -1027,6 +1029,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
let service =
|
||||
@@ -1179,6 +1182,7 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
});
|
||||
let service =
|
||||
|
||||
@@ -533,6 +533,7 @@ mod existing_workspace_provisioning {
|
||||
// This fixture contains only the schema-87 cutover authorities, not the
|
||||
// unrelated full Server schema. Exercise the real service/store methods.
|
||||
Arc::new(SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
@@ -644,6 +645,7 @@ mod existing_workspace_provisioning {
|
||||
drop(service);
|
||||
drop(store);
|
||||
let store = Arc::new(SqliteWorkspaceStore {
|
||||
db_executor: crate::db_executor::DbExecutor::shared(),
|
||||
conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())),
|
||||
});
|
||||
let service = WorkspaceSigningIdentityService::new(
|
||||
@@ -816,7 +818,7 @@ mod current_authority {
|
||||
use super::*;
|
||||
|
||||
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let runtime_key =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
|
||||
store.with_conn(|conn| {
|
||||
|
||||
@@ -37,7 +37,10 @@ use std::{
|
||||
};
|
||||
use workdir::{
|
||||
Workdir, WorkdirError, WorkdirSessionHandle,
|
||||
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
|
||||
http::{
|
||||
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization,
|
||||
WorkdirHttpAuthorizationFuture,
|
||||
},
|
||||
};
|
||||
use worker_runtime::RuntimeWorkspaceScope;
|
||||
use worker_runtime::catalog::{
|
||||
@@ -464,6 +467,10 @@ pub struct WorkerSpawnWorkingDirectoryRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerTicketAssignmentRequest {
|
||||
/// Observed delegation required for Orchestrator Ticket starts; manual starts omit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub orchestrator_assignment_id: Option<String>,
|
||||
|
||||
pub ticket_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
@@ -1300,11 +1307,13 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
})
|
||||
}
|
||||
|
||||
/// Absence means unsupported observation, not failed authorization. Live binding/signing
|
||||
/// and bounded admission failures propagate explicitly, never as None or bearer fallback.
|
||||
fn observation_source(
|
||||
&self,
|
||||
_worker_id: &str,
|
||||
) -> Option<crate::observation::RuntimeObservationSource> {
|
||||
None
|
||||
) -> Result<Option<crate::observation::RuntimeObservationSource>, RuntimeDiagnostic> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn send_input(&self, worker_id: &str, _request: WorkerInputRequest) -> WorkerInputResult {
|
||||
@@ -2242,6 +2251,11 @@ impl RuntimeRegistry {
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
runtime
|
||||
.observation_source(worker_id)
|
||||
.map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_owned(),
|
||||
code: diagnostic.code,
|
||||
message: diagnostic.message,
|
||||
})?
|
||||
.ok_or_else(|| RuntimeRegistryError::UnknownWorker {
|
||||
worker: worker.clone(),
|
||||
})
|
||||
@@ -3209,17 +3223,21 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
) -> Option<crate::observation::RuntimeObservationSource> {
|
||||
let worker_ref = self.worker_ref(worker_id)?;
|
||||
) -> Result<Option<crate::observation::RuntimeObservationSource>, RuntimeDiagnostic> {
|
||||
let Some(worker_ref) = self.worker_ref(worker_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if self.runtime.worker_detail(&worker_ref).is_err() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
Some(crate::observation::RuntimeObservationSource::embedded(
|
||||
crate::observation::EmbeddedRuntimeObservationSource {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
runtime: self.runtime.clone(),
|
||||
worker_ref,
|
||||
},
|
||||
Ok(Some(
|
||||
crate::observation::RuntimeObservationSource::embedded(
|
||||
crate::observation::EmbeddedRuntimeObservationSource {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
runtime: self.runtime.clone(),
|
||||
worker_ref,
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -3485,6 +3503,41 @@ impl WorkspaceRuntimeAuthorization {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offload the complete live-binding check and request signing operation.
|
||||
/// Claims are minted inside the bounded job, not cached before admission. Async subscription
|
||||
/// and Workdir callbacks await this stage before network sends; stale/unavailable authority
|
||||
/// must not fall back to a configured bearer. Synchronous adapters keep their explicit
|
||||
/// blocking boundary, not a synchronous signing callback invoked directly on Tokio.
|
||||
pub(crate) async fn issue_async(
|
||||
&self,
|
||||
method: String,
|
||||
path_and_query: String,
|
||||
operation: String,
|
||||
worker_id: Option<String>,
|
||||
body: Vec<u8>,
|
||||
) -> Result<String, RuntimeDiagnostic> {
|
||||
let executor = self.store.db_executor();
|
||||
let authorization = self.clone();
|
||||
executor
|
||||
.run(move || {
|
||||
Ok::<_, Error>(authorization.issue(
|
||||
&method,
|
||||
&path_and_query,
|
||||
&operation,
|
||||
worker_id.as_deref(),
|
||||
&body,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
diagnostic(
|
||||
"workspace_runtime_authorization_unavailable",
|
||||
HostDiagnosticSeverity::Error,
|
||||
error.to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
pub(crate) fn issue(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -3657,25 +3710,24 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization {
|
||||
impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
method: &str,
|
||||
path_and_query: &str,
|
||||
body: &[u8],
|
||||
) -> Result<String, WorkdirError> {
|
||||
if let Some(authorization) = &self.workspace_authorization {
|
||||
return authorization
|
||||
.issue(
|
||||
method,
|
||||
path_and_query,
|
||||
workspace_runtime_operation(method, path_and_query),
|
||||
None,
|
||||
body,
|
||||
method: String,
|
||||
path_and_query: String,
|
||||
body: Vec<u8>,
|
||||
) -> WorkdirHttpAuthorizationFuture {
|
||||
let authorization = self.clone();
|
||||
Box::pin(async move {
|
||||
if let Some(authorizer) = &authorization.workspace_authorization {
|
||||
let operation = workspace_runtime_operation(&method, &path_and_query).to_string();
|
||||
return authorizer
|
||||
.issue_async(method, path_and_query, operation, None, body)
|
||||
.await
|
||||
.map_err(|error| WorkdirError::Unavailable(error.message));
|
||||
}
|
||||
authorization.fallback_bearer_token.ok_or_else(|| {
|
||||
WorkdirError::Unavailable(
|
||||
"remote Runtime does not have bearer authorization configured".to_string(),
|
||||
)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.message));
|
||||
}
|
||||
self.fallback_bearer_token.clone().ok_or_else(|| {
|
||||
WorkdirError::Unavailable(
|
||||
"remote Runtime does not have bearer authorization configured".to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5298,20 +5350,22 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
fn observation_source(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
) -> Option<crate::observation::RuntimeObservationSource> {
|
||||
) -> Result<Option<crate::observation::RuntimeObservationSource>, RuntimeDiagnostic> {
|
||||
let path = format!("/v1/workers/{worker_id}/protocol/ws");
|
||||
let bearer_token = match &self.workspace_authorization {
|
||||
Some(authorization) => authorization
|
||||
.issue("GET", &path, "workers:protocol", Some(worker_id), &[])
|
||||
.ok(),
|
||||
Some(authorization) => {
|
||||
Some(authorization.issue("GET", &path, "workers:protocol", Some(worker_id), &[])?)
|
||||
}
|
||||
None => self.bearer_token.clone(),
|
||||
};
|
||||
Some(crate::observation::RuntimeObservationSource::remote_ws(
|
||||
crate::observation::RuntimeObservationSourceConfig {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
endpoint: self.ws_endpoint(worker_id),
|
||||
bearer_token,
|
||||
},
|
||||
Ok(Some(
|
||||
crate::observation::RuntimeObservationSource::remote_ws(
|
||||
crate::observation::RuntimeObservationSourceConfig {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id),
|
||||
endpoint: self.ws_endpoint(worker_id),
|
||||
bearer_token,
|
||||
},
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -6355,6 +6409,79 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
|
||||
use crate::db_executor::{DbExecutor, DbExecutorLimits};
|
||||
use crate::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
|
||||
use crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore;
|
||||
|
||||
let executor = DbExecutor::new(DbExecutorLimits {
|
||||
workers: std::num::NonZeroUsize::new(1).unwrap(),
|
||||
queued: std::num::NonZeroUsize::new(1).unwrap(),
|
||||
})
|
||||
.unwrap();
|
||||
let store = Arc::new(
|
||||
SqliteWorkspaceStore::in_memory_for_test()
|
||||
.unwrap()
|
||||
.with_db_executor(executor.clone()),
|
||||
);
|
||||
let service = WorkspaceSigningIdentityService::new(
|
||||
store.clone(),
|
||||
Arc::new(InMemoryWorkspaceSigningMaterialStore::default()),
|
||||
);
|
||||
let authorization = RemoteWorkdirAuthorization {
|
||||
workspace_authorization: Some(WorkspaceRuntimeAuthorization::new(
|
||||
store,
|
||||
service,
|
||||
"https://backend.test",
|
||||
WorkspaceRuntimeBinding {
|
||||
workspace_id: "workspace".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
display_name: "Runtime".into(),
|
||||
base_url: "https://runtime.test".into(),
|
||||
public_key_fingerprint: "unused".into(),
|
||||
public_key: "unused".into(),
|
||||
binding_id: "binding".into(),
|
||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
revoked_at: None,
|
||||
},
|
||||
)),
|
||||
fallback_bearer_token: Some("must-not-be-used".into()),
|
||||
};
|
||||
|
||||
// Hold the running slot and fill the only queue slot. The callback must
|
||||
// reject admission, not run inline or silently switch authentication.
|
||||
let (release, wait) = std::sync::mpsc::channel::<()>();
|
||||
let (started, running) = tokio::sync::oneshot::channel();
|
||||
executor
|
||||
.dispatch(move || {
|
||||
let _ = started.send(());
|
||||
let _ = wait.recv_timeout(Duration::from_secs(5));
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), running)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
executor.dispatch(|| {}).unwrap();
|
||||
let error = authorization
|
||||
.bearer_token(
|
||||
"POST".into(),
|
||||
"/v1/workdir-sessions/s/operations".into(),
|
||||
b"{}".to_vec(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unavailable(message) if message == Error::DbCapacityExceeded.to_string()
|
||||
));
|
||||
drop(release);
|
||||
executor.drain().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_spawn_intent_uses_worker_wire_role() {
|
||||
let intent = WorkerSpawnIntent::TicketRole {
|
||||
@@ -7556,6 +7683,7 @@ mod tests {
|
||||
fn worker_spawn_idempotency_fingerprint_covers_canonical_initial_submit() {
|
||||
let mut request = embedded_spawn_request();
|
||||
request.ticket_assignment = Some(WorkerTicketAssignmentRequest {
|
||||
orchestrator_assignment_id: None,
|
||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ struct Fixture {
|
||||
impl Fixture {
|
||||
async fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
|
||||
let store =
|
||||
Arc::new(SqliteWorkspaceStore::open_for_test(temp.path().join("server.db")).unwrap());
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: "owner".into(),
|
||||
|
||||
@@ -328,31 +328,7 @@ CREATE TABLE server_secret_objects (
|
||||
REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_assignment_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assignment_id TEXT,
|
||||
expected_assignment_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
request_fingerprint TEXT,
|
||||
claim_state TEXT NOT NULL DEFAULT 'committed'
|
||||
CHECK(claim_state IN ('pending', 'failed', 'committed')),
|
||||
failure_reason TEXT,
|
||||
binding_recovery_json TEXT,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(claim_state = 'committed' OR assignment_id IS NULL),
|
||||
CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))
|
||||
);
|
||||
-- Ticket-owned assignment schema is composed by store.rs.
|
||||
CREATE TABLE ticket_assignment_ticket_tombstones (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
@@ -368,72 +344,9 @@ CREATE TABLE ticket_assignment_worker_tombstones (
|
||||
PRIMARY KEY (workspace_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_current_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
assignment_id TEXT NOT NULL,
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, assignment_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id, role, assignment_id)
|
||||
REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, ticket_id)
|
||||
REFERENCES typed_tickets(workspace_id, ticket_id)
|
||||
ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
CREATE TABLE ticket_worker_assignment_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
event_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
|
||||
assignment_id TEXT,
|
||||
previous_assignment_id TEXT,
|
||||
actor TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
operation_id TEXT,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE ticket_worker_assignments (
|
||||
workspace_id TEXT NOT NULL,
|
||||
ticket_id TEXT NOT NULL,
|
||||
assignment_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
|
||||
principal_kind TEXT NOT NULL DEFAULT 'worker'
|
||||
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
|
||||
principal_id TEXT,
|
||||
runtime_id TEXT,
|
||||
worker_id TEXT,
|
||||
assigned_by TEXT NOT NULL,
|
||||
assigned_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id),
|
||||
UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE workspace_runtime_bindings (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
@@ -1165,14 +1078,10 @@ CREATE INDEX idx_repository_ssh_credentials_workspace_status
|
||||
ON repository_ssh_credentials(workspace_id, status, credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host
|
||||
ON repository_ssh_host_trusts(workspace_id, hostname, port);
|
||||
CREATE INDEX idx_ticket_assignment_operations_ticket
|
||||
ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignment_events_ticket
|
||||
ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignments_principal
|
||||
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
|
||||
CREATE INDEX idx_ticket_worker_assignments_ticket
|
||||
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
|
||||
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_workspace_runtime_bindings_workspace
|
||||
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);
|
||||
CREATE INDEX idx_typed_ticket_relations_workspace_target
|
||||
@@ -1202,11 +1111,8 @@ CREATE INDEX idx_workspace_resource_keys_reverse
|
||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
||||
CREATE INDEX repositories_workspace_provider_idx
|
||||
ON repositories(workspace_id, provider);
|
||||
CREATE INDEX ticket_current_principal_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
|
||||
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||
WHERE role IN ('orchestrator', 'worker');
|
||||
|
||||
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||
@@ -1683,3 +1589,114 @@ CREATE TABLE repository_secret_legacy_receipts (
|
||||
FOREIGN KEY (workspace_id, operation_id)
|
||||
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- T715: responsibility is retained; closing irreversibly releases an assignment identity.
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
-- Ticket case assignment work constraints (schema 91).
|
||||
DROP VIEW IF EXISTS ticket_active_worker_assignments;
|
||||
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 = 'open'
|
||||
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);
|
||||
DROP TRIGGER IF EXISTS ticket_terminal_releases_work;
|
||||
CREATE TRIGGER ticket_terminal_releases_work
|
||||
AFTER UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state = '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;
|
||||
DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_insert;
|
||||
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 = 'closed')
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO ticket_assignment_work_releases
|
||||
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
|
||||
END;
|
||||
DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_update;
|
||||
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 = 'closed')
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO ticket_assignment_work_releases
|
||||
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
|
||||
END;
|
||||
DROP TRIGGER IF EXISTS ticket_active_worker_role_insert;
|
||||
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 = 'open')
|
||||
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;
|
||||
DROP TRIGGER IF EXISTS ticket_active_worker_role_update;
|
||||
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 = 'open')
|
||||
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.
|
||||
DROP TRIGGER IF EXISTS ticket_active_worker_principal_insert;
|
||||
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;
|
||||
DROP TRIGGER IF EXISTS ticket_active_worker_principal_update;
|
||||
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;
|
||||
DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_delete;
|
||||
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;
|
||||
DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_move;
|
||||
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;
|
||||
|
||||
@@ -45,7 +45,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
|
||||
let artifacts = evidence_rows(&conn, "artifacts");
|
||||
let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts");
|
||||
drop(conn);
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
upgraded
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
@@ -73,7 +73,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for store in [upgraded, SqliteWorkspaceStore::in_memory().unwrap()] {
|
||||
for store in [upgraded, SqliteWorkspaceStore::in_memory_for_test().unwrap()] {
|
||||
store.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'")?;
|
||||
let objects = stmt.query_map([],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod backend_job;
|
||||
pub mod companion;
|
||||
pub mod config;
|
||||
pub mod config_source;
|
||||
pub mod db_executor;
|
||||
pub mod feature_storage;
|
||||
pub mod hosts;
|
||||
pub mod memory_backend;
|
||||
@@ -155,6 +156,12 @@ pub enum Error {
|
||||
WorkspaceSigningIdentity { code: String, message: String },
|
||||
#[error("Workspace Drive error: {0}")]
|
||||
Drive(#[from] workspace_drive::Error),
|
||||
#[error("DB execution capacity exceeded")]
|
||||
DbCapacityExceeded,
|
||||
#[error("DB executor unavailable")]
|
||||
DbExecutorUnavailable,
|
||||
#[error("DB operation outcome unknown; do not automatically retry")]
|
||||
DbOperationOutcomeUnknown,
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
async fn authority() -> (TempDir, SqliteWorkspaceAuthority) {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let db_path = temp.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
|
||||
@@ -32,7 +32,7 @@ fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() {
|
||||
drop(conn);
|
||||
for attempt in 0..3 {
|
||||
let error = match attempt {
|
||||
0 => SqliteWorkspaceStore::open(&path).err().unwrap(),
|
||||
0 => SqliteWorkspaceStore::open_for_test(&path).err().unwrap(),
|
||||
1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(),
|
||||
_ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(),
|
||||
};
|
||||
@@ -62,7 +62,7 @@ fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() {
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT input_fingerprint FROM worker_removal_operations WHERE operation_id='removal'",[],|r|r.get::<_,String>(0))?,"legacy-receipt");
|
||||
@@ -109,7 +109,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
assert!(!table_exists(&conn, "server_secret_objects").unwrap());
|
||||
drop(conn);
|
||||
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||
let (nonce, mut sealed): (Vec<u8>,Vec<u8>) = conn.query_row(
|
||||
|
||||
@@ -2290,7 +2290,7 @@ mod tests {
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = dir.path().join("server.db");
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap());
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database).unwrap());
|
||||
let timestamp = now();
|
||||
for workspace_id in ["workspace-a", "workspace-b"] {
|
||||
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
|
||||
|
||||
@@ -1116,7 +1116,7 @@ mod tests {
|
||||
WorkerId::from_legacy_u64(1)
|
||||
}
|
||||
fn setup() -> SqliteWorkspaceStore {
|
||||
let s = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let s = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
s.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) \
|
||||
@@ -1136,7 +1136,7 @@ mod tests {
|
||||
)?;
|
||||
c.execute(
|
||||
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
|
||||
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
|
||||
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -1398,7 +1398,7 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
s.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','open',1)", [])?;
|
||||
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?;
|
||||
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
|
||||
@@ -1522,24 +1522,38 @@ mod tests {
|
||||
install_current_assignment(s, "assignment").unwrap();
|
||||
}
|
||||
|
||||
fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore {
|
||||
fn reopened_terminal_assignment_store() -> SqliteWorkspaceStore {
|
||||
let store = setup();
|
||||
seed_current_assignment(&store);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state=?1
|
||||
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[terminal_state],
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='planning'
|
||||
"UPDATE typed_tickets SET status='open', workflow_state='open'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Narrow lifecycle-schema evidence: closing durably releases work,
|
||||
// while reopening retains routing without reviving that assignment.
|
||||
let released: i64 = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_assignment_work_releases
|
||||
WHERE workspace_id='w' AND assignment_id='assignment'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(StoreError::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(released, 1);
|
||||
store
|
||||
}
|
||||
|
||||
@@ -1646,103 +1660,93 @@ mod tests {
|
||||
|
||||
#[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
|
||||
for reopen in [false, true] {
|
||||
let store = setup();
|
||||
seed_current_assignment(&store);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
|
||||
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'
|
||||
[],
|
||||
)?;
|
||||
if reopen {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status='open', workflow_state='open'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.update_worker_retention_policy(
|
||||
"w",
|
||||
&store
|
||||
.worker_retention_policy("w")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&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(),
|
||||
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)
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.update_worker_retention_policy(
|
||||
"w",
|
||||
&store
|
||||
.worker_retention_policy("w")
|
||||
.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
|
||||
.unwrap()
|
||||
.policy_digest,
|
||||
&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(), "closed case, 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(),
|
||||
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
|
||||
[],
|
||||
|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"
|
||||
);
|
||||
}
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(retained, historical),
|
||||
(1, 1),
|
||||
"assignment history must survive removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1768,42 +1772,40 @@ mod tests {
|
||||
|
||||
#[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, .. }
|
||||
let store = reopened_terminal_assignment_store();
|
||||
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()
|
||||
);
|
||||
}
|
||||
);
|
||||
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 store = reopened_terminal_assignment_store();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
@@ -1837,7 +1839,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn commit_rechecks_distinct_assignment_independently_of_admission_fence() {
|
||||
let store = reopened_terminal_assignment_store("done");
|
||||
let store = reopened_terminal_assignment_store();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
@@ -1938,7 +1940,7 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='done'
|
||||
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
|
||||
@@ -965,7 +965,7 @@ async fn connect_runtime(
|
||||
let mut request = endpoint
|
||||
.into_client_request()
|
||||
.map_err(|error| format!("invalid Runtime subscription endpoint: {error}"))?;
|
||||
if let Some(token) = runtime_token(config, workspace_id)? {
|
||||
if let Some(token) = runtime_token(config, workspace_id).await? {
|
||||
request.headers_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {token}")
|
||||
@@ -1021,13 +1021,20 @@ fn runtime_endpoint(base_url: &str) -> String {
|
||||
format!("{base}/v1/protocol/ws")
|
||||
}
|
||||
}
|
||||
fn runtime_token(
|
||||
async fn runtime_token(
|
||||
config: &RemoteRuntimeConfig,
|
||||
_workspace_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
if let Some(authorization) = config.workspace_authorization.as_ref() {
|
||||
return authorization
|
||||
.issue("GET", "/v1/protocol/ws", "workers:list", None, &[])
|
||||
.issue_async(
|
||||
"GET".to_string(),
|
||||
"/v1/protocol/ws".to_string(),
|
||||
"workers:list".to_string(),
|
||||
None,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|error| error.message);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,20 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("upgraded.db");
|
||||
prepare_retained_schema(&path, 85);
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for table in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_assignment_work_releases",
|
||||
"artifacts",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
|
||||
+7960
-5583
File diff suppressed because it is too large
Load Diff
@@ -65,70 +65,85 @@ pub(super) fn require_candidate_grant(
|
||||
/// candidate decision. Once Idle is confirmed, stop cancels any legacy surface
|
||||
/// background work; the new Job regenerates it and does not infer surface success.
|
||||
pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -> ApiResult<bool> {
|
||||
let job_api = api.clone();
|
||||
let subject_id = subject_id.to_owned();
|
||||
let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}");
|
||||
let Some(owner) = api
|
||||
.store
|
||||
.current_worker_singleton_owner(api.workspace_id(), &key)?
|
||||
else {
|
||||
return Ok(true);
|
||||
let (worker, complete) = api
|
||||
.host_executors
|
||||
.host()?
|
||||
.run(move || {
|
||||
let api = &job_api;
|
||||
let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}");
|
||||
let Some(owner) = api
|
||||
.store
|
||||
.current_worker_singleton_owner(api.workspace_id(), &key)?
|
||||
else {
|
||||
return Ok((None, true));
|
||||
};
|
||||
let mut summary = api
|
||||
.runtime
|
||||
.worker(&owner.worker)
|
||||
.map_err(|e| e.into_error())?;
|
||||
summary.singleton_key = Some(owner.key.clone());
|
||||
require_dedicated_subjektiv_consolidation_worker(&summary, api.workspace_id(), &key)?;
|
||||
let stopped = summary.state.eq_ignore_ascii_case("stopped");
|
||||
let idle = summary.state.eq_ignore_ascii_case("idle")
|
||||
&& summary
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.state == protocol::WorkerState::Idle);
|
||||
if !stopped && !idle {
|
||||
return Ok((None, false));
|
||||
}
|
||||
if api
|
||||
.store
|
||||
.current_worker_singleton_owner(api.workspace_id(), &key)?
|
||||
.as_ref()
|
||||
.map(|o| &o.worker)
|
||||
!= Some(&owner.worker)
|
||||
{
|
||||
return Err(Error::RepositoryConflict(
|
||||
"legacy consolidation owner moved during cutover".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !summary.state.eq_ignore_ascii_case("stopped") {
|
||||
let stopped = api
|
||||
.runtime
|
||||
.stop_worker(
|
||||
&owner.worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some(
|
||||
"Retire legacy subjektiv consolidator after foreground completion"
|
||||
.into(),
|
||||
),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.into_error())?;
|
||||
if stopped.state != InternalWorkerOperationState::Accepted {
|
||||
return Ok((None, false));
|
||||
}
|
||||
}
|
||||
if !api
|
||||
.runtime
|
||||
.worker(&owner.worker)
|
||||
.map_err(|e| e.into_error())?
|
||||
.state
|
||||
.eq_ignore_ascii_case("stopped")
|
||||
{
|
||||
return Ok((None, false));
|
||||
}
|
||||
Ok::<_, ApiError>((Some(owner.worker), false))
|
||||
})
|
||||
.await?;
|
||||
let Some(worker) = worker else {
|
||||
return Ok(complete);
|
||||
};
|
||||
let mut summary = api
|
||||
.runtime
|
||||
.worker(&owner.worker)
|
||||
.map_err(|e| e.into_error())?;
|
||||
summary.singleton_key = Some(owner.key.clone());
|
||||
require_dedicated_subjektiv_consolidation_worker(&summary, api.workspace_id(), &key)?;
|
||||
let stopped = summary.state.eq_ignore_ascii_case("stopped");
|
||||
let idle = summary.state.eq_ignore_ascii_case("idle")
|
||||
&& summary
|
||||
.worker_state
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.state == protocol::WorkerState::Idle);
|
||||
if !stopped && !idle {
|
||||
return Ok(false);
|
||||
}
|
||||
if api
|
||||
.store
|
||||
.current_worker_singleton_owner(api.workspace_id(), &key)?
|
||||
.as_ref()
|
||||
.map(|o| &o.worker)
|
||||
!= Some(&owner.worker)
|
||||
{
|
||||
return Err(Error::RepositoryConflict(
|
||||
"legacy consolidation owner moved during cutover".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !summary.state.eq_ignore_ascii_case("stopped") {
|
||||
let stopped = api
|
||||
.runtime
|
||||
.stop_worker(
|
||||
&owner.worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some(
|
||||
"Retire legacy subjektiv consolidator after foreground completion".into(),
|
||||
),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.into_error())?;
|
||||
if stopped.state != InternalWorkerOperationState::Accepted {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if !api
|
||||
.runtime
|
||||
.worker(&owner.worker)
|
||||
.map_err(|e| e.into_error())?
|
||||
.state
|
||||
.eq_ignore_ascii_case("stopped")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let removal = WorkerRemovalService::new(api)
|
||||
.execute_target_removal(
|
||||
api.runtime.as_ref(),
|
||||
&owner.worker,
|
||||
&worker,
|
||||
"subjektiv consolidation Job cutover",
|
||||
None,
|
||||
)
|
||||
@@ -141,10 +156,17 @@ pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(api
|
||||
.store
|
||||
.current_worker_singleton_owner(api.workspace_id(), &key)?
|
||||
.is_none())
|
||||
let store = api.store.clone();
|
||||
let workspace_id = api.workspace_id().to_owned();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
store
|
||||
.current_worker_singleton_owner(&workspace_id, &key)
|
||||
.map(|owner| owner.is_none())
|
||||
.map_err(ApiError::from)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn bounded_candidate_batch(
|
||||
|
||||
@@ -131,8 +131,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
|
||||
})
|
||||
.unwrap();
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Cancellation logging");
|
||||
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||
let input = ticket::NewTicket::new("Cancellation logging");
|
||||
let ticket = backend.create(input).unwrap();
|
||||
api.store
|
||||
.set_current_ticket_worker_assignment(
|
||||
@@ -197,8 +196,8 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
|
||||
"{diagnostics}"
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket.id).unwrap().state,
|
||||
"inprogress"
|
||||
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
|
||||
"open"
|
||||
);
|
||||
|
||||
// Errors without diagnostics still carry a useful kind and message.
|
||||
@@ -213,7 +212,32 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
|
||||
*execution.cancel_failure.lock().unwrap() = None;
|
||||
let (status, body, events) = request_with_logs(app, request("body-secret")).await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(body["state"], "ready");
|
||||
assert_eq!(body["state"], "open");
|
||||
assert!(
|
||||
api.store
|
||||
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let assignment_history = api
|
||||
.store
|
||||
.list_ticket_worker_assignment_events(TEST_WORKSPACE_ID, &ticket.id, 10)
|
||||
.unwrap();
|
||||
assert!(
|
||||
assignment_history
|
||||
.iter()
|
||||
.any(|event| event.action == "unassigned"
|
||||
&& event.previous_assignment_id.as_deref() == Some("logging-assignment"))
|
||||
);
|
||||
let after = backend.show(ticket.id.into()).unwrap();
|
||||
assert!(after.case_history.is_empty());
|
||||
assert!(after.current_closure.is_none());
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"successful cancellation logged as failure: {events:?}"
|
||||
@@ -782,7 +806,19 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc
|
||||
.unwrap();
|
||||
};
|
||||
let ((status, body, events), ()) = tokio::join!(
|
||||
request_with_logs(build_router(fixture.api.clone()), request),
|
||||
async {
|
||||
let result = request_with_logs(build_router(fixture.api.clone()), request).await;
|
||||
// A pre-dispatch rejection leaves the fake provider waiting on an
|
||||
// open channel. Fail with the actual response here instead of
|
||||
// hanging join! until the outer suite timeout hides that cause.
|
||||
assert_eq!(
|
||||
result.0,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"early command-start response: {}",
|
||||
result.1
|
||||
);
|
||||
result
|
||||
},
|
||||
provider
|
||||
);
|
||||
assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}");
|
||||
@@ -808,3 +844,78 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc
|
||||
"post-dispatch failure was retried"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saturated_host_before_command_start_returns_availability_without_a_provider_operation() {
|
||||
let mut fixture = manual_worker_assignment_fixture().await;
|
||||
let identity =
|
||||
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
|
||||
.unwrap();
|
||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
||||
let connection = Arc::new(ExternalProviderConnection {
|
||||
grant_id: "overload-grant".into(),
|
||||
workdir_id: fixture.main_workdir_id.clone(),
|
||||
provider_instance_id: "overload-provider".into(),
|
||||
generation: 1,
|
||||
expires_at: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
sender,
|
||||
});
|
||||
fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| {
|
||||
Ok(Arc::new(ExternalProviderWorkdirSession::new(
|
||||
connection.clone(),
|
||||
None,
|
||||
)) as WorkdirSessionHandle)
|
||||
}));
|
||||
fixture
|
||||
.api
|
||||
.runtime
|
||||
.register_or_replace(fixture.runtime.clone());
|
||||
let host = fixture.api.host_executors.host().unwrap();
|
||||
host.drain().await.unwrap();
|
||||
let mut releases = Vec::new(); // dropping senders also releases jobs on panic
|
||||
for _ in 0..2 {
|
||||
let (release, permit) = std::sync::mpsc::channel::<()>();
|
||||
releases.push(release);
|
||||
let (started, accepted) = tokio::sync::oneshot::channel();
|
||||
host.dispatch(move || {
|
||||
let _ = started.send(());
|
||||
let _ = permit.recv_timeout(std::time::Duration::from_secs(10));
|
||||
})
|
||||
.unwrap();
|
||||
accepted.await.unwrap();
|
||||
}
|
||||
for _ in 0..16 {
|
||||
host.dispatch(|| {}).unwrap();
|
||||
}
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||
let request = runtime_source_request(
|
||||
&identity,
|
||||
Some(&fixture.worker.worker_id),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(&json!({
|
||||
"target_workdir": "checkout",
|
||||
"operation": {"operation": "command_start", "request": {
|
||||
"command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024,
|
||||
"cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched"
|
||||
}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
let (status, body, _) = request_with_logs(build_router(fixture.api.clone()), request).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}");
|
||||
assert!(body.to_string().contains("db_capacity_exceeded"), "{body}");
|
||||
// The response is already final but the live fake-provider receiver remains
|
||||
// Pending. An unbounded join of those two futures hid this causal rejection
|
||||
// behind the original 600-second suite timeout. No process or command ran.
|
||||
let provider_command = commands.recv();
|
||||
tokio::pin!(provider_command);
|
||||
assert!(futures::poll!(provider_command.as_mut()).is_pending());
|
||||
drop(releases);
|
||||
host.drain().await.unwrap();
|
||||
fixture.api.store.db_executor().drain().await.unwrap();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret");
|
||||
SqliteWorkspaceStore::open(&api.config.database_path)
|
||||
SqliteWorkspaceStore::open_for_test(&api.config.database_path)
|
||||
.unwrap()
|
||||
.revoke_workspace_runtime_binding(
|
||||
TEST_WORKSPACE_ID,
|
||||
@@ -70,7 +70,7 @@ async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_
|
||||
let body = "body-secret";
|
||||
let apps = [
|
||||
build_router(api.clone()),
|
||||
workspace_server_router(WorkspaceServerApi::new(
|
||||
workspace_server_router(WorkspaceServerApi::new_for_test(
|
||||
api.config.clone(),
|
||||
api.store.clone(),
|
||||
)),
|
||||
@@ -318,7 +318,7 @@ async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_m
|
||||
&error,
|
||||
)
|
||||
});
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
@@ -347,7 +347,7 @@ async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() {
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories");
|
||||
for app in [
|
||||
build_router(api.clone()),
|
||||
workspace_server_router(WorkspaceServerApi::new(
|
||||
workspace_server_router(WorkspaceServerApi::new_for_test(
|
||||
api.config.clone(),
|
||||
api.store.clone(),
|
||||
)),
|
||||
|
||||
@@ -721,6 +721,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay(
|
||||
token_id_hash: None,
|
||||
runtime_id: workers[0].runtime_id.clone(),
|
||||
worker_id: Some(workers[0].worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
},
|
||||
));
|
||||
let response = app
|
||||
@@ -867,6 +868,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
context.runtime_source = Some(ServerRuntimeSource {
|
||||
runtime_id: workers[0].runtime_id.clone(),
|
||||
worker_id: Some(workers[1].worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
drive::root(&api, &context, TEST_WORKSPACE_ID)
|
||||
|
||||
@@ -40,7 +40,7 @@ async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let api = WorkspaceApi::new(config.clone(), store.clone())
|
||||
let api = WorkspaceApi::new_for_test(config.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let cached = api.drive().await.unwrap();
|
||||
@@ -104,7 +104,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature
|
||||
// Use a separate embedded-runtime namespace for the first API: this test
|
||||
// reconstructs Drive authority in-process rather than spawning/restarting
|
||||
// product processes (runtime observers may retain their namespace).
|
||||
let first_api = WorkspaceApi::new(config.clone(), store.clone())
|
||||
let first_api = WorkspaceApi::new_for_test(config.clone(), store.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
// Merely constructing a Workspace API must not create blob storage.
|
||||
@@ -160,7 +160,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
drop(first_api);
|
||||
let recovered = WorkspaceServerApi::new(config.clone(), store.clone());
|
||||
let recovered = WorkspaceServerApi::new_for_test(config.clone(), store.clone());
|
||||
let api = recovered
|
||||
.api_for_workspace(&config.workspace_id)
|
||||
.await
|
||||
|
||||
@@ -39,10 +39,10 @@ impl RealDriveFixture {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(temp.path());
|
||||
config.repositories.clear();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
|
||||
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
|
||||
let second = seed_test_api_token(store.as_ref(), "drive-web-second");
|
||||
let server_api = WorkspaceServerApi::new(config, store.clone());
|
||||
let server_api = WorkspaceServerApi::new_for_test(config, store.clone());
|
||||
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
|
||||
// Exercise T-717's real repositoryless Workspace bootstrap, not a
|
||||
// pre-existing test Workspace with its repository merely hidden in UI.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
use super::*;
|
||||
use crate::db_executor::{
|
||||
DbExecutor, DbExecutorLimits, DbOperationLifetime, with_operation_lifetime,
|
||||
};
|
||||
use std::{
|
||||
num::NonZeroUsize,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn owner() -> DbExecutor {
|
||||
DbExecutor::new(DbExecutorLimits {
|
||||
workers: NonZeroUsize::new(1).unwrap(),
|
||||
queued: NonZeroUsize::new(1).unwrap(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
async fn observed<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||
tokio::time::timeout(Duration::from_secs(2), future)
|
||||
.await
|
||||
.expect("owned protocol stage did not progress")
|
||||
}
|
||||
struct Release(Option<mpsc::Sender<()>>);
|
||||
impl Release {
|
||||
fn release(&mut self) {
|
||||
if let Some(tx) = self.0.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Release {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
struct Lifetime(Arc<AtomicUsize>);
|
||||
struct Retained(Arc<AtomicUsize>);
|
||||
impl Drop for Retained {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
impl DbOperationLifetime for Lifetime {
|
||||
fn retain(&self) -> Box<dyn Send> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Box::new(Retained(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_embedded_endpoint_capture_keeps_actual_host_budget_off_tokio() {
|
||||
let (runtime, worker, execution) = runtime_with_worker_backend();
|
||||
let (started, held) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut release = Release(Some(tx));
|
||||
let thread = std::thread::spawn(move || {
|
||||
let _guard = execution.contexts.lock().unwrap();
|
||||
let _ = started.send(());
|
||||
let _ = rx.recv_timeout(Duration::from_secs(5));
|
||||
});
|
||||
observed(held).await.unwrap();
|
||||
let executor = owner();
|
||||
let leases = Arc::new(AtomicUsize::new(0));
|
||||
let mut capture = Box::pin(with_operation_lifetime(
|
||||
Arc::new(Lifetime(leases.clone())),
|
||||
connect_embedded_worker_protocol(
|
||||
embedded_protocol_test_source(&runtime, &worker),
|
||||
embedded_protocol_test_scope(),
|
||||
executor.clone(),
|
||||
),
|
||||
));
|
||||
assert!(
|
||||
futures::poll!(&mut capture).is_pending(),
|
||||
"endpoint Runtime lock must not run on Tokio"
|
||||
);
|
||||
observed(tokio::time::sleep(Duration::from_millis(1))).await;
|
||||
assert_eq!(
|
||||
observed(tokio::spawn(async { "control" })).await.unwrap(),
|
||||
"control"
|
||||
);
|
||||
drop(capture);
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 1);
|
||||
executor.dispatch(|| {}).unwrap(); // only the one queued slot is left
|
||||
let error = executor.run(|| Ok::<_, Error>(())).await.unwrap_err();
|
||||
assert!(matches!(error, Error::DbCapacityExceeded));
|
||||
release.release();
|
||||
thread.join().unwrap();
|
||||
observed(executor.drain()).await.unwrap();
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_embedded_shutdown_commits_fs_receipt_after_the_pump_disappears() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let execution = Arc::new(DeterministicExecutionBackend::default());
|
||||
let options = worker_runtime::fs_store::FsRuntimeStoreOptions {
|
||||
root: dir.path().join("runtime"),
|
||||
runtime_id: "owned-protocol".into(),
|
||||
display_name: None,
|
||||
};
|
||||
let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend(
|
||||
options.clone(),
|
||||
execution.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(runtime_test_bundle()).unwrap();
|
||||
let worker = runtime
|
||||
.create_worker_scoped(&embedded_protocol_test_scope(), runtime_create_request())
|
||||
.unwrap()
|
||||
.worker_ref;
|
||||
let executor = owner();
|
||||
let mut connection = connect_embedded_worker_protocol(
|
||||
embedded_protocol_test_source(&runtime, &worker),
|
||||
embedded_protocol_test_scope(),
|
||||
executor.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
observed(connection.events.recv()).await.unwrap(),
|
||||
protocol::Event::Snapshot { .. }
|
||||
));
|
||||
let (entered, held) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut release = Release(Some(tx));
|
||||
let endpoint = execution
|
||||
.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&worker)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let gate_finished = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let finished = gate_finished.clone();
|
||||
*endpoint.dispatch_hook.lock().unwrap() = Some(Box::new(move || {
|
||||
let _ = entered.send(());
|
||||
let _ = rx.recv_timeout(Duration::from_secs(5));
|
||||
finished.store(true, Ordering::SeqCst);
|
||||
}));
|
||||
connection
|
||||
.methods
|
||||
.send(protocol::Method::Shutdown {
|
||||
command: protocol::WorkerCommandEnvelope::new(700),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
observed(held).await.unwrap(); // actual Runtime method, after its durable stop admission
|
||||
observed(tokio::time::sleep(Duration::from_millis(1))).await;
|
||||
assert_eq!(
|
||||
observed(tokio::spawn(async { "control" })).await.unwrap(),
|
||||
"control"
|
||||
);
|
||||
assert!(
|
||||
!gate_finished.load(Ordering::SeqCst),
|
||||
"watchdog bailout is not Tokio progress"
|
||||
);
|
||||
connection
|
||||
.termination
|
||||
.terminate(WorkerProtocolEnd::Disconnected);
|
||||
drop(connection);
|
||||
executor.dispatch(|| {}).unwrap();
|
||||
assert!(matches!(
|
||||
executor.run(|| Ok::<_, Error>(())).await.unwrap_err(),
|
||||
Error::DbCapacityExceeded
|
||||
));
|
||||
release.release();
|
||||
observed(executor.drain()).await.unwrap();
|
||||
assert_eq!(
|
||||
runtime.worker_detail(&worker).unwrap().status,
|
||||
worker_runtime::catalog::WorkerStatus::Stopped
|
||||
);
|
||||
// Read the actual fs-store aggregate written by the accepted method. The
|
||||
// deterministic execution fixture has no restorable Profile archive, so
|
||||
// restarting it would test archive recovery rather than this commit owner.
|
||||
let record: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(
|
||||
options
|
||||
.root
|
||||
.join("workers")
|
||||
.join(worker.worker_id.to_string())
|
||||
.join("worker.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record["status"], "stopped",
|
||||
"cancelled pump lost its fs-store commit: {record}"
|
||||
);
|
||||
assert_ne!(
|
||||
record["execution_state"]["state"],
|
||||
"reconciliation_required"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
use super::*;
|
||||
|
||||
const PROTOCOL_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
// Completion replies are produced synchronously by the actual attached execution.
|
||||
// Unlike flooding the fixture's 32-entry broadcast bus, this reaches the Server's
|
||||
// event admission boundary without first causing unrelated Runtime relay lag.
|
||||
#[tokio::test]
|
||||
async fn embedded_event_overflow_terminates_without_losing_the_admitted_prefix() {
|
||||
let (runtime, worker_ref, execution) = runtime_with_worker_backend();
|
||||
let mut connection = connect_embedded_worker_protocol(
|
||||
embedded_protocol_test_source(&runtime, &worker_ref),
|
||||
embedded_protocol_test_scope(),
|
||||
HostExecutors::default().host().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Leave the snapshot queued. Exactly capacity - 1 completion replies fit;
|
||||
// the next reply must end this connection rather than block its bridge.
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, async {
|
||||
for index in 0..WORKER_PROTOCOL_EVENT_CAPACITY {
|
||||
connection
|
||||
.methods
|
||||
.send(protocol_test_completion(&format!("overflow-{index}")))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
connection.termination.ended().await,
|
||||
WorkerProtocolEnd::EventOverflow
|
||||
);
|
||||
connection.methods.closed().await;
|
||||
})
|
||||
.await
|
||||
.expect("full embedded event queue did not terminate the connection");
|
||||
|
||||
assert_embedded_protocol_snapshot(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
&worker_ref,
|
||||
);
|
||||
for index in 0..WORKER_PROTOCOL_EVENT_CAPACITY - 1 {
|
||||
assert!(matches!(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
protocol::Event::Completions { request_id: Some(id), .. }
|
||||
if id == format!("overflow-{index}")
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, connection.events.recv())
|
||||
.await
|
||||
.expect("overflowed event receiver did not close")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
connection
|
||||
.methods
|
||||
.try_send(protocol_test_completion("after-overflow"))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
execution.protocol_methods().len(),
|
||||
WORKER_PROTOCOL_EVENT_CAPACITY
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_protocol_delivers_snapshot_then_ordered_replies_and_live_events() {
|
||||
let (runtime, worker_ref, execution) = runtime_with_worker_backend();
|
||||
let mut connection = connect_embedded_worker_protocol(
|
||||
embedded_protocol_test_source(&runtime, &worker_ref),
|
||||
embedded_protocol_test_scope(),
|
||||
HostExecutors::default().host().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ["first", "second", "third"] {
|
||||
connection
|
||||
.methods
|
||||
.try_send(protocol_test_completion(id))
|
||||
.unwrap();
|
||||
}
|
||||
assert_embedded_protocol_snapshot(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
&worker_ref,
|
||||
);
|
||||
for expected in ["first", "second", "third"] {
|
||||
assert!(matches!(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
protocol::Event::Completions { request_id: Some(id), .. } if id == expected
|
||||
));
|
||||
}
|
||||
execution.emit_worker_event(
|
||||
&worker_ref,
|
||||
protocol::Event::TextDelta {
|
||||
text: "live-after-replies".into(),
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
protocol::Event::TextDelta { text } if text == "live-after-replies"
|
||||
));
|
||||
assert_eq!(connection.termination.reason(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_overflow_requires_explicit_reconnect_and_stop_ends_the_new_connection() {
|
||||
let (runtime, worker_ref, execution) = runtime_with_worker_backend();
|
||||
let source = embedded_protocol_test_source(&runtime, &worker_ref);
|
||||
let mut old = connect_embedded_worker_protocol(
|
||||
source.clone(),
|
||||
embedded_protocol_test_scope(),
|
||||
HostExecutors::default().host().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_embedded_protocol_snapshot(next_embedded_protocol_event(&mut old).await, &worker_ref);
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, async {
|
||||
for index in 0..=WORKER_PROTOCOL_EVENT_CAPACITY {
|
||||
old.methods
|
||||
.send(protocol_test_completion(&format!("old-{index}")))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
old.termination.ended().await,
|
||||
WorkerProtocolEnd::EventOverflow
|
||||
);
|
||||
old.methods.closed().await;
|
||||
})
|
||||
.await
|
||||
.expect("overflowed connection remained live");
|
||||
|
||||
// Overflow ends only the attached connection, not its Worker. Reconnection
|
||||
// is explicit and starts from a fresh execution snapshot, not old backlog.
|
||||
let mut fresh = connect_embedded_worker_protocol(
|
||||
source,
|
||||
embedded_protocol_test_scope(),
|
||||
HostExecutors::default().host().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_embedded_protocol_snapshot(next_embedded_protocol_event(&mut fresh).await, &worker_ref);
|
||||
fresh
|
||||
.methods
|
||||
.try_send(protocol_test_completion("explicit-reconnect"))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
next_embedded_protocol_event(&mut fresh).await,
|
||||
protocol::Event::Completions { request_id: Some(id), .. } if id == "explicit-reconnect"
|
||||
));
|
||||
assert!(
|
||||
old.methods
|
||||
.try_send(protocol_test_completion("must-not-reattach"))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
old.termination.reason(),
|
||||
Some(WorkerProtocolEnd::EventOverflow)
|
||||
);
|
||||
assert_eq!(
|
||||
execution.protocol_methods().len(),
|
||||
WORKER_PROTOCOL_EVENT_CAPACITY + 2
|
||||
);
|
||||
|
||||
runtime.stop_worker(&worker_ref, None).unwrap();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.termination.ended())
|
||||
.await
|
||||
.expect("Stop did not end the attached protocol connection"),
|
||||
WorkerProtocolEnd::Disconnected
|
||||
);
|
||||
assert_embedded_protocol_closed(&mut fresh).await;
|
||||
}
|
||||
|
||||
async fn backpressure_browser_context(api: &WorkspaceApi) -> (WorkerOperationContext, String) {
|
||||
seed_test_api_token(api.store.as_ref(), "backpressure-browser");
|
||||
let token = "backpressure-browser-session";
|
||||
let token_hash = crate::auth::token_hash(token);
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: token_hash.clone(),
|
||||
session_id: "backpressure-session".into(),
|
||||
user_id: "user-backpressure-browser".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: "2099-01-01T00:00:00Z".into(),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
let AuthConfig::Passkey {
|
||||
cookie_name,
|
||||
origin,
|
||||
..
|
||||
} = &api.config.auth;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ORIGIN, origin.parse().unwrap());
|
||||
headers.insert(
|
||||
axum::http::header::COOKIE,
|
||||
format!("{cookie_name}={token}").parse().unwrap(),
|
||||
);
|
||||
let actor = resolve_actor(&ServerAuthApi::from(api), &headers)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("fixture browser session must authenticate");
|
||||
(
|
||||
WorkerOperationContext::Browser {
|
||||
headers,
|
||||
source: authenticated_browser_input_source(&actor),
|
||||
},
|
||||
token_hash,
|
||||
)
|
||||
}
|
||||
|
||||
async fn undrained_protocol_connection() -> (
|
||||
WorkspaceWorkerProtocolConnection,
|
||||
tokio::sync::mpsc::Receiver<protocol::Method>,
|
||||
) {
|
||||
let (receiver_sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let connection = spawn_worker_protocol_pump(move |methods, events| async move {
|
||||
receiver_sender.send(methods).unwrap();
|
||||
// Keep the event endpoint live until common termination cancels this
|
||||
// fixture pump. Method consumption is controlled solely by the test.
|
||||
let _events = events;
|
||||
std::future::pending::<WorkerProtocolEnd>().await
|
||||
});
|
||||
let receiver = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, receiver)
|
||||
.await
|
||||
.expect("fixture pump did not expose its undrained method endpoint")
|
||||
.unwrap();
|
||||
(connection, receiver)
|
||||
}
|
||||
|
||||
async fn full_protocol_method_queue() -> (
|
||||
WorkspaceWorkerProtocolConnection,
|
||||
tokio::sync::mpsc::Receiver<protocol::Method>,
|
||||
) {
|
||||
let (connection, receiver) = undrained_protocol_connection().await;
|
||||
for index in 0..WORKER_PROTOCOL_METHOD_CAPACITY {
|
||||
connection
|
||||
.methods
|
||||
.try_send(protocol_test_completion(&format!("queued-{index}")))
|
||||
.unwrap();
|
||||
}
|
||||
(connection, receiver)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_method_queue_overflow_fails_fast_and_does_not_pin_session_cleanup() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "method-overflow");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
let (connection, mut receiver) = full_protocol_method_queue().await;
|
||||
let termination = connection.termination.clone();
|
||||
let sender =
|
||||
WorkspaceWorkerMethodSender::new(worker.clone(), connection.methods, termination.clone());
|
||||
let _events = connection.events;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
sender.send(
|
||||
&context,
|
||||
protocol::Method::Cancel {
|
||||
command: protocol::WorkerCommandEnvelope::new(801),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("method admission blocked while holding the session guard");
|
||||
assert!(result.is_err(), "full method queue must reject Cancel too");
|
||||
assert_eq!(
|
||||
termination.reason(),
|
||||
Some(WorkerProtocolEnd::MethodOverflow)
|
||||
);
|
||||
assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY);
|
||||
assert!(matches!(
|
||||
receiver.try_recv().unwrap(),
|
||||
protocol::Method::ListCompletions { request_id: Some(id), .. } if id == "queued-0"
|
||||
));
|
||||
assert!(
|
||||
sender
|
||||
.send(
|
||||
&context,
|
||||
protocol_test_completion("must-not-retry-after-drain")
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY - 1);
|
||||
|
||||
let stopped = tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
worker.stop(
|
||||
&WorkerOperationContext::Backend,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some("cleanup-after-method-overflow".into()),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("full method queue pinned Stop's session cleanup")
|
||||
.unwrap();
|
||||
assert_eq!(stopped.state, InternalWorkerOperationState::Accepted);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
for index in 1..WORKER_PROTOCOL_METHOD_CAPACITY {
|
||||
assert!(matches!(
|
||||
receiver.try_recv().unwrap(),
|
||||
protocol::Method::ListCompletions { request_id: Some(id), .. }
|
||||
if id == format!("queued-{index}")
|
||||
));
|
||||
}
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoked_browser_credentials_reject_before_method_overflow_admission() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "revoked-full-queue");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, token_hash) = backpressure_browser_context(&api).await;
|
||||
let (connection, receiver) = full_protocol_method_queue().await;
|
||||
let termination = connection.termination.clone();
|
||||
let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone());
|
||||
let _events = connection.events;
|
||||
assert!(
|
||||
api.store
|
||||
.revoke_browser_session(&token_hash, &now_registry_timestamp())
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
sender.send(&context, protocol_test_completion("unauthorized")),
|
||||
)
|
||||
.await
|
||||
.expect("revoked credentials waited for method capacity");
|
||||
assert!(matches!(result, Err(Error::WorkspacePermissionDenied(_))));
|
||||
assert_eq!(termination.reason(), None);
|
||||
assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_pending_browser_admission_does_not_enqueue_or_hold_the_session_guard() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "cancel-pending-admission");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
let (connection, mut receiver) = undrained_protocol_connection().await;
|
||||
let termination = connection.termination.clone();
|
||||
let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone());
|
||||
let _events = connection.events;
|
||||
let session_lock = current_worker_session_lock(&api, &identity);
|
||||
let guard = session_lock.lock().await;
|
||||
{
|
||||
let admission = sender.send(&context, protocol_test_completion("cancelled-admission"));
|
||||
tokio::pin!(admission);
|
||||
assert!(futures::poll!(admission.as_mut()).is_pending());
|
||||
// Dropping a blocked admission future must not schedule a detached retry.
|
||||
}
|
||||
drop(guard);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
let guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock())
|
||||
.await
|
||||
.expect("cancelled admission retained the session guard");
|
||||
close_current_worker_session_locked(&api, &identity)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(guard);
|
||||
|
||||
let command = protocol::WorkerCommandEnvelope::new(802);
|
||||
sender
|
||||
.send(&context, protocol::Method::Cancel { command })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
receiver.try_recv().unwrap(),
|
||||
protocol::Method::Cancel { command: admitted } if admitted == command
|
||||
));
|
||||
assert!(receiver.try_recv().is_err());
|
||||
assert_eq!(termination.reason(), None);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
}
|
||||
|
||||
// In-process browser transport: one real JSON method frame, then an idle
|
||||
// connection. Its writes are immediately ready, isolating authorization waits
|
||||
// from transport congestion and without crossing a complete product flow.
|
||||
struct AdmissionCancellationSocket {
|
||||
inbound: Option<WsMessage>,
|
||||
written: Vec<WsMessage>,
|
||||
}
|
||||
|
||||
impl futures::Stream for AdmissionCancellationSocket {
|
||||
type Item = std::result::Result<WsMessage, axum::Error>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
match self.inbound.take() {
|
||||
Some(message) => std::task::Poll::Ready(Some(Ok(message))),
|
||||
None => std::task::Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Sink<WsMessage> for AdmissionCancellationSocket {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn poll_ready(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn start_send(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
message: WsMessage,
|
||||
) -> std::result::Result<(), Self::Error> {
|
||||
self.written.push(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_close(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_protocol_termination_cancels_admission_waiting_for_session_authority() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "browser-authorization-cancellation");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
let (connection, mut receiver) = undrained_protocol_connection().await;
|
||||
let termination = connection.termination.clone();
|
||||
let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone());
|
||||
let connection = worker_operations::WorkspaceWorkerConnection {
|
||||
sender,
|
||||
events: connection.events,
|
||||
};
|
||||
let session_lock = current_worker_session_lock(&api, &identity);
|
||||
let guard = session_lock.lock().await;
|
||||
let method = protocol::Method::Cancel {
|
||||
command: protocol::WorkerCommandEnvelope::new(803),
|
||||
};
|
||||
let mut socket = AdmissionCancellationSocket {
|
||||
inbound: Some(WsMessage::Text(
|
||||
protocol::stream::encode_method(&method).unwrap().into(),
|
||||
)),
|
||||
written: Vec::new(),
|
||||
};
|
||||
{
|
||||
let session = workspace_worker_protocol_session(connection, &mut socket, context);
|
||||
tokio::pin!(session);
|
||||
// SQLite browser authorization is synchronous; this poll consumes the
|
||||
// frame and reaches the deliberately held session lock.
|
||||
assert!(futures::poll!(session.as_mut()).is_pending());
|
||||
termination.terminate(WorkerProtocolEnd::Disconnected);
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session)
|
||||
.await
|
||||
.expect("terminal connection did not cancel pending browser admission");
|
||||
}
|
||||
assert!(matches!(socket.written.last(), Some(WsMessage::Close(_))));
|
||||
assert!(receiver.try_recv().is_err());
|
||||
drop(guard);
|
||||
let _guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock())
|
||||
.await
|
||||
.expect("cancelled browser authorization pinned session cleanup");
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
}
|
||||
|
||||
struct BackpressureTestServer(tokio::task::JoinHandle<()>);
|
||||
|
||||
impl Drop for BackpressureTestServer {
|
||||
fn drop(&mut self) {
|
||||
self.0.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_protocol_session_ends_on_full_method_queue_without_blocking_cleanup() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "browser-session-overflow");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
let (connection, receiver) = full_protocol_method_queue().await;
|
||||
let termination = connection.termination.clone();
|
||||
let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone());
|
||||
let events = connection.events;
|
||||
let (session_done, finished) = tokio::sync::oneshot::channel();
|
||||
let pending = Arc::new(std::sync::Mutex::new(Some((
|
||||
worker_operations::WorkspaceWorkerConnection { sender, events },
|
||||
session_done,
|
||||
))));
|
||||
let app = Router::new().route(
|
||||
"/protocol/ws",
|
||||
get(move |ws: WebSocketUpgrade| {
|
||||
let pending = pending.clone();
|
||||
let context = context.clone();
|
||||
async move {
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
let (connection, session_done) = pending.lock().unwrap().take().unwrap();
|
||||
workspace_worker_protocol_session(connection, socket, context).await;
|
||||
let _ = session_done.send(());
|
||||
})
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let _server = BackpressureTestServer(tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}));
|
||||
let (mut socket, _) = connect_async(format!("ws://{address}/protocol/ws"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
send_protocol_test_ws_method(&mut socket, &protocol_test_completion("browser-overflow")),
|
||||
)
|
||||
.await
|
||||
.expect("browser method frame did not reach the loopback transport");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, termination.ended())
|
||||
.await
|
||||
.expect("browser session did not report method overflow"),
|
||||
WorkerProtocolEnd::MethodOverflow
|
||||
);
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, finished)
|
||||
.await
|
||||
.expect("browser protocol session did not finish after overflow")
|
||||
.expect("browser protocol session dropped its completion signal");
|
||||
let session_lock = current_worker_session_lock(&api, &identity);
|
||||
let _guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock())
|
||||
.await
|
||||
.expect("browser method overflow pinned session cleanup");
|
||||
close_current_worker_session_locked(&api, &identity)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
}
|
||||
|
||||
// Accepting an output frame is not proof of delivery: this browser transport
|
||||
// never finishes flushing, including the final best-effort Close frame.
|
||||
struct StalledBrowserOutput {
|
||||
entered: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
dropped: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
written: Arc<std::sync::Mutex<Vec<WsMessage>>>,
|
||||
}
|
||||
|
||||
impl futures::Stream for StalledBrowserOutput {
|
||||
type Item = std::result::Result<WsMessage, axum::Error>;
|
||||
|
||||
fn poll_next(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl futures::Sink<WsMessage> for StalledBrowserOutput {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn poll_ready(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn start_send(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
message: WsMessage,
|
||||
) -> std::result::Result<(), Self::Error> {
|
||||
self.written.lock().unwrap().push(message);
|
||||
if let Some(entered) = self.entered.take() {
|
||||
let _ = entered.send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
|
||||
fn poll_close(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StalledBrowserOutput {
|
||||
fn drop(&mut self) {
|
||||
if let Some(dropped) = self.dropped.take() {
|
||||
let _ = dropped.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_bounded_unknown_delivery_close(messages: &[WsMessage]) {
|
||||
let Some(WsMessage::Close(Some(frame))) = messages.last() else {
|
||||
panic!("terminal browser session must attempt an explicit Close frame: {messages:?}");
|
||||
};
|
||||
assert_eq!(frame.code, 1011);
|
||||
assert!(frame.reason.contains("delivery unknown"), "{frame:?}");
|
||||
assert!(frame.reason.contains("no retry"), "{frame:?}");
|
||||
assert!(
|
||||
frame.reason.len() < 123,
|
||||
"Close reason exceeds its wire bound: {frame:?}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_same_worker_reconnect_and_stop(
|
||||
worker: &WorkspaceWorker,
|
||||
context: &WorkerOperationContext,
|
||||
) {
|
||||
let mut fresh = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, worker.connect_protocol(context))
|
||||
.await
|
||||
.expect("terminated transport retained same Worker's connection authority")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.events.recv())
|
||||
.await
|
||||
.expect("same Worker reconnect did not deliver a fresh snapshot"),
|
||||
Some(protocol::Event::Snapshot { .. })
|
||||
));
|
||||
let stopped = tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
worker.stop(
|
||||
&WorkerOperationContext::Backend,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some("cleanup-after-stalled-protocol".into()),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("terminated transport pinned same Worker's Stop/session cleanup")
|
||||
.unwrap();
|
||||
assert_eq!(stopped.state, InternalWorkerOperationState::Accepted);
|
||||
assert_eq!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.sender.termination.ended())
|
||||
.await
|
||||
.expect("Stop did not terminate the fresh same Worker connection"),
|
||||
WorkerProtocolEnd::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_stalled_browser_output_reclaimed(explicit_termination: bool) {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "stalled-browser-output");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
// Use the real attached embedded execution snapshot as browser output.
|
||||
let raw = connect_workspace_worker_protocol(&api, &identity, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let methods = raw.methods.clone();
|
||||
let termination = raw.termination;
|
||||
let connection = worker_operations::WorkspaceWorkerConnection {
|
||||
sender: WorkspaceWorkerMethodSender::new(worker.clone(), raw.methods, termination.clone()),
|
||||
events: raw.events,
|
||||
};
|
||||
let (entered_sender, entered) = tokio::sync::oneshot::channel();
|
||||
let (dropped_sender, dropped) = tokio::sync::oneshot::channel();
|
||||
let written = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let socket = StalledBrowserOutput {
|
||||
entered: Some(entered_sender),
|
||||
dropped: Some(dropped_sender),
|
||||
written: written.clone(),
|
||||
};
|
||||
let started = tokio::time::Instant::now();
|
||||
{
|
||||
let session = workspace_worker_protocol_session(connection, socket, context.clone());
|
||||
tokio::pin!(session);
|
||||
assert!(futures::poll!(session.as_mut()).is_pending());
|
||||
// Poll the real session alongside its output-acceptance oneshot rather
|
||||
// than assuming how many scheduler turns the attached pump needs.
|
||||
let output_accepted = async {
|
||||
tokio::select! {
|
||||
result = entered => result.expect("browser sink dropped before receiving output"),
|
||||
_ = session.as_mut() => panic!("browser session ended before accepting output"),
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, output_accepted)
|
||||
.await
|
||||
.expect("attached snapshot never reached the stalled browser output");
|
||||
{
|
||||
let messages = written.lock().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
let WsMessage::Text(text) = &messages[0] else {
|
||||
panic!("first browser output must be the attached snapshot");
|
||||
};
|
||||
assert!(matches!(
|
||||
protocol::stream::decode_event(text.as_str()).unwrap(),
|
||||
protocol::Event::Snapshot { .. }
|
||||
));
|
||||
}
|
||||
|
||||
if explicit_termination {
|
||||
termination.terminate(WorkerProtocolEnd::Disconnected);
|
||||
} else {
|
||||
tokio::time::advance(std::time::Duration::from_secs(9)).await;
|
||||
assert!(futures::poll!(session.as_mut()).is_pending());
|
||||
assert_eq!(termination.reason(), None);
|
||||
tokio::time::advance(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
assert!(futures::poll!(session.as_mut()).is_pending());
|
||||
assert_eq!(
|
||||
termination.reason(),
|
||||
Some(if explicit_termination {
|
||||
WorkerProtocolEnd::Disconnected
|
||||
} else {
|
||||
WorkerProtocolEnd::TransportTimeout
|
||||
}),
|
||||
"browser end must publish the concrete common termination cause"
|
||||
);
|
||||
{
|
||||
let messages = written.lock().unwrap();
|
||||
assert_eq!(messages.len(), 2, "must not retry uncertain browser output");
|
||||
assert_bounded_unknown_delivery_close(&messages);
|
||||
}
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed())
|
||||
.await
|
||||
.expect("browser termination did not cancel its actual attached pump");
|
||||
let session_lock = current_worker_session_lock(&api, &identity);
|
||||
let guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock())
|
||||
.await
|
||||
.expect("blocked browser Close pinned session ownership");
|
||||
close_current_worker_session_locked(&api, &identity)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(guard);
|
||||
|
||||
// The terminal Close itself is stalled. It must get a separate bounded
|
||||
// attempt, not become a second unbounded output wait.
|
||||
tokio::time::advance(std::time::Duration::from_secs(9)).await;
|
||||
assert!(futures::poll!(session.as_mut()).is_pending());
|
||||
tokio::time::advance(std::time::Duration::from_secs(1)).await;
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session.as_mut())
|
||||
.await
|
||||
.expect("stalled terminal Close did not reach its deadline");
|
||||
}
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, dropped)
|
||||
.await
|
||||
.expect("completed browser session retained its stalled sink")
|
||||
.expect("browser sink dropped without its ownership signal");
|
||||
assert_eq!(written.lock().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
tokio::time::Instant::now() - started,
|
||||
std::time::Duration::from_secs(if explicit_termination { 10 } else { 20 })
|
||||
);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
// SQLite jobs run on external threads; paused Tokio time cannot observe their
|
||||
// progress and may auto-advance a reconnect timeout before SQL completes.
|
||||
tokio::time::resume();
|
||||
assert_same_worker_reconnect_and_stop(&worker, &context).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn browser_blocked_output_deadline_ends_pump_and_bounds_the_terminal_close() {
|
||||
assert_stalled_browser_output_reclaimed(false).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn browser_common_termination_preempts_blocked_output_and_bounds_the_terminal_close() {
|
||||
assert_stalled_browser_output_reclaimed(true).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_disconnect_and_last_owner_drop_release_the_execution_event_subscription() {
|
||||
for explicit_termination in [false, true] {
|
||||
let (runtime, worker_ref, execution) = runtime_with_worker_backend();
|
||||
let endpoint = execution
|
||||
.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&worker_ref)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
let mut connection = connect_embedded_worker_protocol(
|
||||
embedded_protocol_test_source(&runtime, &worker_ref),
|
||||
embedded_protocol_test_scope(),
|
||||
HostExecutors::default().host().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_embedded_protocol_snapshot(
|
||||
next_embedded_protocol_event(&mut connection).await,
|
||||
&worker_ref,
|
||||
);
|
||||
assert_eq!(endpoint.events.receiver_count(), 1);
|
||||
let WorkspaceWorkerProtocolConnection {
|
||||
methods,
|
||||
mut events,
|
||||
termination,
|
||||
} = connection;
|
||||
let last_owner = termination.clone();
|
||||
drop(termination);
|
||||
assert!(!methods.is_closed());
|
||||
if explicit_termination {
|
||||
last_owner.terminate(WorkerProtocolEnd::Disconnected);
|
||||
// Keep the owner alive until watch cancellation is observed so
|
||||
// abort-on-drop cannot hide a broken explicit-disconnect path.
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed())
|
||||
.await
|
||||
.expect("embedded explicit disconnect did not reclaim its pump");
|
||||
}
|
||||
// Both channel endpoints remain held. Without explicit termination,
|
||||
// only dropping the last client owner can cancel this attached pump.
|
||||
drop(last_owner);
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed())
|
||||
.await
|
||||
.expect("embedded last-owner drop did not reclaim its method pump");
|
||||
assert!(
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, events.recv())
|
||||
.await
|
||||
.expect("reclaimed embedded event endpoint stayed open")
|
||||
.is_none()
|
||||
);
|
||||
// The fixture's execution relay notices its dropped receiver on its
|
||||
// next event. Wake that relay once, then observe subscription closure.
|
||||
let _ = endpoint.events.send(protocol::Event::TextDelta {
|
||||
text: "execution-relay-close-probe".into(),
|
||||
});
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, endpoint.events.closed())
|
||||
.await
|
||||
.expect("dropped embedded transport retained its execution event subscription");
|
||||
assert_eq!(endpoint.events.receiver_count(), 0);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
runtime.stop_worker(&worker_ref, None).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_remote_bridge_browser_cancel_overflow_reclaims_wire_and_allows_same_worker_progress()
|
||||
{
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
|
||||
let identity = spawn_ticket_check_source(&api, "remote-browser-overflow");
|
||||
let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap();
|
||||
let (context, _) = backpressure_browser_context(&api).await;
|
||||
let (raw, entered, dropped, sent) = super::worker_protocol_tests::stalled_remote_bridge();
|
||||
raw.methods
|
||||
.try_send(protocol_test_completion("wire-delivery-unknown"))
|
||||
.unwrap();
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, entered)
|
||||
.await
|
||||
.expect("actual remote pump did not enter its stalled wire send")
|
||||
.unwrap();
|
||||
for index in 0..WORKER_PROTOCOL_METHOD_CAPACITY {
|
||||
raw.methods
|
||||
.try_send(protocol_test_completion(&format!("remote-queued-{index}")))
|
||||
.unwrap();
|
||||
}
|
||||
let methods = raw.methods.clone();
|
||||
let termination = raw.termination;
|
||||
let connection = worker_operations::WorkspaceWorkerConnection {
|
||||
sender: WorkspaceWorkerMethodSender::new(worker.clone(), raw.methods, termination.clone()),
|
||||
events: raw.events,
|
||||
};
|
||||
let cancel = protocol::Method::Cancel {
|
||||
command: protocol::WorkerCommandEnvelope::new(804),
|
||||
};
|
||||
let mut socket = AdmissionCancellationSocket {
|
||||
inbound: Some(WsMessage::Text(
|
||||
protocol::stream::encode_method(&cancel).unwrap().into(),
|
||||
)),
|
||||
written: Vec::new(),
|
||||
};
|
||||
// This real browser session decodes Cancel and passes the authenticated
|
||||
// context through the actual method sender into the saturated remote pump.
|
||||
tokio::time::timeout(
|
||||
PROTOCOL_TEST_TIMEOUT,
|
||||
workspace_worker_protocol_session(connection, &mut socket, context.clone()),
|
||||
)
|
||||
.await
|
||||
.expect("browser Cancel waited for the stalled remote wire instead of failing admission");
|
||||
assert_eq!(
|
||||
termination.reason(),
|
||||
Some(WorkerProtocolEnd::MethodOverflow)
|
||||
);
|
||||
assert!(socket.inbound.is_none());
|
||||
assert_bounded_unknown_delivery_close(&socket.written);
|
||||
tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, dropped)
|
||||
.await
|
||||
.expect("method overflow did not drop the stalled remote sink")
|
||||
.unwrap();
|
||||
assert!(methods.is_closed());
|
||||
{
|
||||
let messages = sent.lock().unwrap();
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
1,
|
||||
"must not retry or drain unknown remote delivery"
|
||||
);
|
||||
let TungsteniteMessage::Text(text) = &messages[0] else {
|
||||
panic!("remote pump must send an actual encoded method frame");
|
||||
};
|
||||
assert!(matches!(
|
||||
decode_method(text.as_str()).unwrap(),
|
||||
protocol::Method::ListCompletions { request_id: Some(id), .. }
|
||||
if id == "wire-delivery-unknown"
|
||||
));
|
||||
}
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
assert_same_worker_reconnect_and_stop(&worker, &context).await;
|
||||
}
|
||||
@@ -1,34 +1,45 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_session_callback_completes_while_create_and_deletion_are_waiting() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let subjektiv = open_subjektiv_store(&api).unwrap();
|
||||
let subject = subjektiv
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reserved = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
"runtime-test",
|
||||
"subject-start",
|
||||
&"f".repeat(64),
|
||||
Some(&singleton_key),
|
||||
&memory,
|
||||
)
|
||||
.unwrap();
|
||||
let worker_id = reserved.worker_id.to_string();
|
||||
let server = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
|
||||
// Reuse the fixture API, but exercise the real outer dispatcher and both auth layers.
|
||||
let mut callbacks = Vec::new();
|
||||
for index in 0..2 {
|
||||
let subject = subjektiv
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new(format!("companion-{index}")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
|
||||
let reserved = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
"runtime-test",
|
||||
&format!("subject-start-{index}"),
|
||||
&"f".repeat(64),
|
||||
Some(&singleton_key),
|
||||
&memory,
|
||||
)
|
||||
.unwrap();
|
||||
callbacks.push((
|
||||
subject.id,
|
||||
reserved.worker_id.to_string(),
|
||||
session_store::new_session_id().to_string(),
|
||||
));
|
||||
}
|
||||
let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone());
|
||||
// Real dispatcher, source proof authentication, live singleton authority and
|
||||
// attribution SQL. Only Runtime's create/restore wait is modeled below; no
|
||||
// product process or complete create endpoint is spawned by this route test.
|
||||
server
|
||||
.apis
|
||||
.lock()
|
||||
@@ -39,61 +50,210 @@ async fn subject_session_callback_completes_while_create_and_deletion_are_waitin
|
||||
build_inner_router(api.clone()),
|
||||
);
|
||||
let gate = server.admission_for_workspace(TEST_WORKSPACE_ID).await;
|
||||
let create = gate.admit(false).unwrap();
|
||||
let mut deletion = Box::pin(gate.lock_deletion());
|
||||
assert!(futures::poll!(&mut deletion).is_pending());
|
||||
let host = api.host_executors.host().unwrap(); // production 2 workers/16 queued
|
||||
let mut parents = Vec::new();
|
||||
let mut finish_callbacks = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let create = gate.admit(false).unwrap();
|
||||
let (entered, ready) = tokio::sync::oneshot::channel();
|
||||
let (finish_callback, callback_reply) = std::sync::mpsc::channel();
|
||||
finish_callbacks.push(finish_callback);
|
||||
parents.push(tokio::spawn(run_host_workflow(host.clone(), async move {
|
||||
let _create = create;
|
||||
entered.send(()).unwrap();
|
||||
// The actual Host parent cannot finish until its mandatory callbacks
|
||||
// return. Timeout/disconnection is failure cleanup, never success.
|
||||
callback_reply
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|error| {
|
||||
Error::InvalidInput(format!("Runtime callback did not complete: {error}"))
|
||||
})?;
|
||||
Ok::<_, Error>(())
|
||||
})));
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), ready)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
let app = workspace_server_router(server);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
|
||||
let session_id = session_store::new_session_id().to_string();
|
||||
let body = serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true }))
|
||||
.unwrap();
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
app.clone().oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(&worker_id),
|
||||
"POST",
|
||||
&path,
|
||||
body.clone(),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("Session attribution must not wait for its own Worker creation")
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{response_body:?}");
|
||||
let response: Value = serde_json::from_slice(&response_body).unwrap();
|
||||
assert_eq!(response["subject_id"], subject.id);
|
||||
assert_eq!(response["session_id"], session_id);
|
||||
let stored = subjektiv.session_attribution(&session_id).unwrap().unwrap();
|
||||
assert_eq!(stored.worker_id, worker_id);
|
||||
assert_eq!(stored.subject_id, subject.id);
|
||||
|
||||
// The callback lane does not bypass authentication.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
app.clone().oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
let memory_path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory");
|
||||
for (_, worker_id, _) in &callbacks {
|
||||
// Resident context is also a pure Subject callback used by Runtime
|
||||
// bootstrap/restore. It must not reenter the waiting Host lane either.
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
app.clone().oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&memory_path,
|
||||
serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
|
||||
Default::default(),
|
||||
),
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("resident context must not wait for its Host parents")
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{body:?}");
|
||||
let resident: server_api::SubjektivMemoryBackendResponse =
|
||||
serde_json::from_slice(&body).unwrap();
|
||||
assert!(matches!(
|
||||
resident,
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(_)
|
||||
));
|
||||
}
|
||||
// Resident memory is not an allowed deletion-drain callback. Preserve that
|
||||
// admission fence; only the exact Session attribution route reenters below.
|
||||
let mut deletion = Box::pin(gate.lock_deletion());
|
||||
assert!(futures::poll!(&mut deletion).is_pending());
|
||||
drop(create);
|
||||
for (subject_id, worker_id, session_id) in &callbacks {
|
||||
let body =
|
||||
serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true }))
|
||||
.unwrap();
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
app.clone().oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&path,
|
||||
body,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("Session attribution must not wait for its Host parents")
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{response_body:?}");
|
||||
let response: Value = serde_json::from_slice(&response_body).unwrap();
|
||||
assert_eq!(response["subject_id"], *subject_id);
|
||||
assert_eq!(response["session_id"], *session_id);
|
||||
let stored = subjektiv.session_attribution(session_id).unwrap().unwrap();
|
||||
assert_eq!(stored.worker_id, *worker_id);
|
||||
assert_eq!(stored.subject_id, *subject_id);
|
||||
}
|
||||
// Neither actual parent nor its admission lease was released to make these
|
||||
// callbacks succeed. Only durable attribution completion releases them.
|
||||
assert!(parents.iter().all(|parent| !parent.is_finished()));
|
||||
assert!(futures::poll!(&mut deletion).is_pending());
|
||||
for finish_callback in finish_callbacks {
|
||||
finish_callback.send(()).unwrap();
|
||||
}
|
||||
for parent in parents {
|
||||
parent.await.unwrap().unwrap();
|
||||
}
|
||||
host.drain().await.unwrap();
|
||||
api.store.db_executor().drain().await.unwrap();
|
||||
let _deletion = tokio::time::timeout(std::time::Duration::from_secs(2), deletion)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(gate.admit(true).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consolidation_subject_callbacks_are_denied_without_runtime_lookup() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let subjektiv = open_subjektiv_store(&api).unwrap();
|
||||
let subject = subjektiv
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{}", subject.id);
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
"runtime-test",
|
||||
"consolidation-start",
|
||||
&"f".repeat(64),
|
||||
Some(&key),
|
||||
&memory,
|
||||
)
|
||||
.unwrap();
|
||||
// runtime-test has no Worker adapter: authority denial must not try to look
|
||||
// up its profile/tags through Runtime before rejecting this disallowed role.
|
||||
let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone());
|
||||
server
|
||||
.apis
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), api.clone());
|
||||
server
|
||||
.routers
|
||||
.lock()
|
||||
.await
|
||||
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
|
||||
let app = workspace_server_router(server);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
|
||||
let worker_id = reservation.worker_id.to_string();
|
||||
let session_id = session_store::new_session_id().to_string();
|
||||
for (callback_path, callback_body) in [
|
||||
(
|
||||
path.clone(),
|
||||
serde_json::to_vec(&json!({"session_id": session_id, "create_if_missing": true}))
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory"),
|
||||
serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
|
||||
Default::default(),
|
||||
),
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(&worker_id),
|
||||
"POST",
|
||||
&callback_path,
|
||||
callback_body,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{callback_path}: {body:?}");
|
||||
}
|
||||
assert!(
|
||||
subjektiv
|
||||
.session_attribution(&session_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({"session_id": session_id, "create_if_missing": true}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_deletion_waits_for_session_attribution_and_fences_both_lanes() {
|
||||
let gate = Arc::new(WorkspaceAdmission::default());
|
||||
|
||||
@@ -53,6 +53,7 @@ fn context(worker: &RuntimeWorkerRef) -> server_api::ServerRequestContext {
|
||||
runtime_source: Some(server_api::ServerRuntimeSource {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
worker_id: Some(worker.worker_id.clone()),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
}),
|
||||
origin: None,
|
||||
transport_headers: vec![],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user