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:
2026-10-13 02:46:50 +09:00
213 changed files with 59018 additions and 14443 deletions
+1
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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;
+22 -7
View File
@@ -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;
+24 -4
View File
@@ -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",
+97 -55
View File
@@ -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
);
}
+465 -226
View File
@@ -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(&current),
expected_state: parse(&change.from)?,
state: parse(&change.to)?,
reason: if change.body.as_str().is_empty() {
change.reason.clone()
} else {
format!("{}\n\n{}", change.reason, change.body.as_str())
},
references: change.references.clone(),
author: change.author.clone(),
})
}
pub fn set_ticket_workflow_state(
&self,
id: &TicketIdOrSlug,
change: &TicketStateChange,
_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(&current),
expected_state: current.meta.workflow_state,
reason: resolution.as_str().to_string(),
references: Vec::new(),
author: None,
};
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(id);
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"
);
}
+1 -1
View File
@@ -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
View File
@@ -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;
+362
View File
@@ -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
View File
@@ -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");
+45
View File
@@ -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());
}
+9 -25
View File
@@ -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
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!(
+2
View File
@@ -67,6 +67,8 @@ pub enum LoggedSessionHistoryOrigin {
ToolOutput {
worker: LoggedWorkerSubject,
},
/// Host-produced opening context summary; not auto-read or TaskStore material.
CompactionSummary,
DerivedSummary,
LegacyUnknown,
}
+8 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+122
View File
@@ -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)
}
+105
View File
@@ -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');
+964
View File
@@ -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
);
}
+32
View File
@@ -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;
+70
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+671 -92
View File
@@ -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());
}
+337
View File
@@ -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)
})
}
}
+804
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+800
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+818
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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};
+4
View File
@@ -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;
+59 -9
View File
@@ -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();
+184
View File
@@ -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};
+16 -18
View File
@@ -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
+140 -9
View File
@@ -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"
);
}
+72 -4
View File
@@ -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 {
+8
View File
@@ -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,
+40 -4
View File
@@ -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)
}
+1
View File
@@ -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);
})),
+54 -5
View File
@@ -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();
+162 -12
View File
@@ -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"
);
}
}
+60 -2
View File
@@ -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(
+104 -2
View File
@@ -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
View File
@@ -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 {
+737 -1
View File
@@ -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
+198 -4
View File
@@ -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() {
+357 -127
View File
@@ -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
+6 -6
View File
@@ -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(&current, vec![update_main(&current, "{ 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 [
+971
View File
@@ -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| {
+168 -40
View File
@@ -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(),
+121 -104
View File
@@ -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<_>>>()?;
+7
View File
@@ -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 {
+134 -132
View File
@@ -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",
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