51 Commits
Author SHA1 Message Date
Hare 15bc299987 fix: align integrated validation contracts 2026-09-05 06:49:06 +09:00
Hare 87bdb0c6ed chore: integrate develop into hare/develop candidate 2026-09-05 04:15:47 +09:00
Hare aa96bbedbc fix: harden Memory restore and scheduling 2026-09-05 00:24:00 +09:00
Hare 4df277c81f fix: preserve Memory consolidation enablement 2026-09-04 23:53:59 +09:00
Hare 12646b6ca0 refactor: install Memory prompt contributions through Feature 2026-09-04 23:40:12 +09:00
Hare 1e674d70c2 refactor: unify Memory feature configuration authority 2026-09-04 22:55:37 +09:00
Hare 5ee77698db fix: gate workspace memory lifecycle authority 2026-09-04 20:53:27 +09:00
Hare d1f5661881 test: cover memory lifecycle outcomes 2026-09-04 20:11:29 +09:00
Hare 532d078720 test: exercise memory lifecycle background behavior 2026-09-04 19:41:04 +09:00
Hare 27e5df106f fix: complete memory lifecycle feature boundaries 2026-09-04 19:16:01 +09:00
Hare 33d98868c3 refactor: move memory extraction into lifecycle feature 2026-09-04 18:44:50 +09:00
Hare fb13e53cb5 test: cover start race at rewrite barrier 2026-09-04 16:53:35 +09:00
Hare 60a5495ccd fix: hold feature task barrier across rewrites 2026-09-04 16:34:15 +09:00
Hare f1dc90621c fix: fence feature task shutdown races 2026-09-04 16:13:35 +09:00
Hare eecb116709 fix: bound feature lifecycle execution 2026-09-04 16:04:10 +09:00
Hare 783d25b1c4 fix: project ticket blockers from current DTO shape 2026-09-04 15:47:11 +09:00
Hare af06eecfd0 feat: add executable feature lifecycle hooks 2026-09-04 14:50:44 +09:00
Hare 9bd08a3a5b fix: bound Skill API response handling 2026-09-04 13:55:13 +09:00
Hare 3d66247e11 test: include Skill API contract suite 2026-09-04 13:36:36 +09:00
Hare 4390554477 feat: share Skill REST DTO authority 2026-09-04 13:30:39 +09:00
Hare 74457db4eb Merge commit '89856eb7c3dd6b659ff952d7e3be4a8c0885bf98' into work/T-584-agen-typed-interceptor 2026-09-04 13:01:55 +09:00
Hare 5d61da481b fix: consume internal tool batch cancellation 2026-09-04 13:01:37 +09:00
Hare 89856eb7c3 chore: replace legacy SQLite migrations with baselines 2026-09-04 12:48:45 +09:00
Hare 64c268582d fix: keep runtime SSH sockets below path limits 2026-09-04 12:48:25 +09:00
Hare bb6558e7bf fix: log Worker execution restore failures 2026-09-04 12:48:15 +09:00
Hare 42d109cae3 Merge commit '4ca8ea1694d88db501c48dd4947fdbd6f2828136' into work/T-584-agen-typed-interceptor 2026-09-04 12:28:30 +09:00
Hare 9b48b1ff5d fix: terminalize parallel tool siblings on interceptor stop 2026-09-04 12:27:59 +09:00
Hare 4ca8ea1694 feat: merge REST DTO updates from develop 2026-09-03 19:09:12 +09:00
Hare c10d6c6914 fix: keep Cargo artifacts local to workdirs 2026-09-03 19:06:57 +09:00
Hare 3a94c845cf fix: harden auth response validation 2026-09-03 18:08:43 +09:00
Hare bc810beb3b feat: centralize auth REST contracts 2026-09-03 17:35:24 +09:00
Hare eac4a0c071 fix: harden interceptor lifecycle contracts 2026-09-03 16:46:02 +09:00
Hare c1dfb1add5 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-566-memory-rest-dto 2026-09-03 16:07:53 +09:00
Hare e67f9bee08 Merge branch 'develop' into hare/develop 2026-09-03 16:00:05 +09:00
Hare 7c1d81cee9 chore: optimize build 2026-09-03 15:59:40 +09:00
Hare 5cec2eef60 feat: unify Memory REST DTO authority 2026-09-03 15:54:48 +09:00
Hare e62c7cf4f5 Merge commit '7abc6aca45a82665d33115612148536f3a5dd275' into work/T-584-agen-typed-interceptor 2026-09-03 15:47:21 +09:00
Hare 0245980ea5 refactor: finalize interceptor lifecycle boundaries 2026-09-03 15:33:15 +09:00
Hare 7abc6aca45 Merge commit '80221289935e227820aec902988a4f987273e05e' into work/T-549-provider-published-ref
# Conflicts:
#	crates/worker-runtime/src/runtime.rs
2026-09-03 15:25:34 +09:00
Hare f1bcd41ad9 fix: route embedded repository ref observation 2026-09-03 15:05:30 +09:00
Hare 8022128993 Merge branch 'refs/heads/develop' into work/T-565-worker-launch-rest-dto 2026-09-03 14:54:19 +09:00
Hare 68b1aa64e9 feat: type async interceptor failures 2026-09-03 14:46:07 +09:00
Hare f3af8f21dc fix: validate canonical Worker launch segments 2026-09-03 14:43:53 +09:00
Hare 7c056b1db8 fix: publish merge request ref diagnostic DTO 2026-09-03 14:34:09 +09:00
Hare da14c82f71 fix: expose provider ref blockers without failing reads 2026-09-03 14:25:51 +09:00
Hare 1f32c693df refactor: share Worker launch REST DTOs 2026-09-03 14:23:33 +09:00
Hare 74bfbe941e fix: detach stopped worker execution handles 2026-09-03 13:50:25 +09:00
Hare 2884c08466 fix: harden provider ref review boundaries 2026-09-03 13:49:20 +09:00
Hare 85e1ea320a feat: unify worker lifecycle restore semantics 2026-09-03 13:23:31 +09:00
Hare 7be428d8bf fix: require provider-published merge request refs 2026-09-03 12:56:17 +09:00
Hare 56798f9fb4 feat: observe repository refs through runtime providers 2026-09-03 12:56:06 +09:00
125 changed files with 17673 additions and 12453 deletions
-3
View File
@@ -1,3 +0,0 @@
[build]
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
incremental = false
Generated
+2
View File
@@ -6706,9 +6706,11 @@ dependencies = [
name = "workspace-api"
version = "0.1.0"
dependencies = [
"protocol",
"serde",
"serde_json",
"ts-rs",
"webauthn-rs-proto",
]
[[package]]
+6
View File
@@ -68,6 +68,12 @@ default-members = [
edition = "2024"
license = "MIT"
[profile.dev]
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
[workspace.dependencies]
# Internal crates
client = { path = "crates/client" }
+2 -2
View File
@@ -4,7 +4,7 @@
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
use agen::{Engine, EngineRunExit, StopReason};
use agen::{Engine, EngineRunExit, RunInterruptionReason};
use std::time::Duration;
#[tokio::main]
@@ -51,7 +51,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
EngineRunExit::Finished => println!("✅ Task completed normally"),
EngineRunExit::Paused => println!("⏸️ Task paused"),
EngineRunExit::Yielded => println!("↩️ Task yielded"),
EngineRunExit::Interrupted(StopReason::LimitReached) => {
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
println!("🔒 Turn limit reached")
}
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
+10 -6
View File
@@ -39,8 +39,8 @@ use tracing::info;
use tracing_subscriber::EnvFilter;
use agen::{
Engine, EngineRunExit, StopReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
Engine, EngineRunExit, RunInterruptionReason,
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
llm_client::{
LlmClient,
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
#[async_trait]
impl Interceptor for ToolResultPrinterPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
let name = self
.call_names
.lock()
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
println!(" Result ({}): ✅ {}", name, info.result.summary);
}
PostToolAction::Continue
Ok(PostToolAction::Continue)
}
}
@@ -478,7 +481,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// One-shot mode
if let Some(prompt) = args.prompt {
let output = engine.run(&mut history, &prompt).await;
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
{
eprintln!("\n❌ Error: {error}");
}
@@ -518,7 +522,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break;
}
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
locked.run(&mut history, input).await
{
eprintln!("\n❌ Error: {error}");
+345 -113
View File
@@ -15,8 +15,12 @@ use crate::{
},
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
interceptor::{
DefaultInterceptor, Interceptor, PostToolAction, PreRequestAction, PreToolAction,
PromptAction, ToolCallInfo, ToolResultInfo, TurnEndAction,
AssistantTurnEndContext, DefaultInterceptor, Interceptor, InterceptorCallId,
InterceptorCounter, InterceptorCounters, InterceptorError, InterceptorErrorCategory,
InterceptorFailure, InterceptorInvocation, InterceptorPhase, InterceptorRunId,
InterceptorTurnId, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, RunExitContext,
ToolCallInfo, ToolResultInfo, TurnEndAction,
},
llm_client::{
ClientError, ConfigWarning, LlmClient, Request, RequestConfig, ResponseStream,
@@ -58,6 +62,9 @@ pub enum EngineError {
/// A durable-history observer rejected an item before it entered history.
#[error("History append failed: {0}")]
HistoryAppend(String),
/// A trusted host interceptor callback failed.
#[error(transparent)]
Interceptor(#[from] InterceptorFailure),
/// Tool terminalization lost its execution-attempt compare-and-set fence.
#[error("Tool execution attempt fence failed: {0}")]
ToolAttemptFence(String),
@@ -147,12 +154,12 @@ pub enum EngineRunExit {
Finished,
Paused,
Yielded,
Interrupted(StopReason),
Interrupted(RunInterruptionReason),
}
/// A typed reason why an engine run could not finish normally.
#[derive(Debug)]
pub enum StopReason {
pub enum RunInterruptionReason {
LimitReached,
ContextWindowExceeded,
Cancelled,
@@ -165,13 +172,15 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
Ok(EngineResult::Finished) => Self::Finished,
Ok(EngineResult::Paused) => Self::Paused,
Ok(EngineResult::Yielded) => Self::Yielded,
Ok(EngineResult::LimitReached) => Self::Interrupted(StopReason::LimitReached),
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
Self::Interrupted(StopReason::ContextWindowExceeded)
Ok(EngineResult::LimitReached) => {
Self::Interrupted(RunInterruptionReason::LimitReached)
}
Err(EngineError::Cancelled) => Self::Interrupted(StopReason::Cancelled),
Err(EngineError::Client(ClientError::ContextWindowExceeded)) => {
Self::Interrupted(RunInterruptionReason::ContextWindowExceeded)
}
Err(EngineError::Cancelled) => Self::Interrupted(RunInterruptionReason::Cancelled),
Err(EngineError::PauseRequested) => Self::Paused,
Err(error) => Self::Interrupted(StopReason::Unexpected(error)),
Err(error) => Self::Interrupted(RunInterruptionReason::Unexpected(error)),
}
}
}
@@ -179,7 +188,7 @@ impl From<Result<EngineResult, EngineError>> for EngineRunExit {
/// Result of [`Engine::run`] or [`Engine::resume`].
///
/// Contains the `Locked` Engine (ready for subsequent runs) and the outcome.
pub struct EngineRunOutput<C: LlmClient, A = ()> {
pub struct EngineRunOutput<C: LlmClient, A: Send + Sync = ()> {
/// The Engine, now in Locked state.
pub engine: Engine<C, Locked, A>,
/// Outcome of the turn.
@@ -303,7 +312,7 @@ enum StreamCompletion {
Interrupted { reason: String },
}
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// LLM client
client: C,
/// Retry policy for opening an LLM response stream.
@@ -320,7 +329,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// Tool server handle
tool_server: ToolServerHandle,
/// Interceptor for control-flow decisions
interceptor: Box<dyn Interceptor>,
interceptor: Box<dyn Interceptor<A>>,
/// System prompt
system_prompt: Option<String>,
/// History length at lock time (only meaningful in Locked state)
@@ -339,6 +348,11 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
/// `max_turns` is enforced against this run-scoped count rather than the
/// cumulative `turn_count` above.
active_run_turn_count: Option<usize>,
/// Identity retained across pause/yield and resume.
active_run_id: Option<InterceptorRunId>,
next_run_id: u64,
interceptor_invocation_count: usize,
last_run_exit_observer_failure: Option<InterceptorFailure>,
/// LlmCall count (per-Engine running counter, monotonic). Unlike
/// `turn_count` this never collapses retries.
llm_call_count: usize,
@@ -419,21 +433,57 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A = ()> {
_state: PhantomData<(S, A)>,
}
impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
fn start_logical_run(&mut self) {
self.active_run_turn_count = Some(0);
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
self.last_run_exit_observer_failure = None;
}
fn ensure_logical_run(&mut self) {
self.active_run_turn_count.get_or_insert(0);
if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
}
fn finish_logical_run(&mut self, result: &Result<EngineResult, EngineError>) {
if !matches!(
result,
Ok(EngineResult::Paused | EngineResult::Yielded) | Err(EngineError::PauseRequested)
) {
fn interceptor_invocation(
&mut self,
phase: InterceptorPhase,
turn_id: Option<usize>,
call_id: Option<InterceptorCallId>,
tool_call: usize,
) -> InterceptorInvocation {
let invocation = self.interceptor_invocation_count;
self.interceptor_invocation_count = self.interceptor_invocation_count.saturating_add(1);
InterceptorInvocation {
run_id: self
.active_run_id
.expect("logical run identity must exist before interception"),
turn_id: turn_id.map(|value| InterceptorTurnId(value as u64)),
call_id,
phase,
counters: InterceptorCounters {
invocation: InterceptorCounter::from_usize(invocation),
engine_turn: InterceptorCounter::from_usize(self.turn_count),
run_turn: InterceptorCounter::from_usize(
self.active_run_turn_count.unwrap_or_default(),
),
llm_call: InterceptorCounter::from_usize(self.llm_call_count),
tool_batch: InterceptorCounter::from_usize(self.tool_execution_batch_count),
tool_call: InterceptorCounter::from_usize(tool_call),
},
}
}
fn finish_logical_run(&mut self, exit: &EngineRunExit) {
if !matches!(exit, EngineRunExit::Paused | EngineRunExit::Yielded) {
self.active_run_turn_count = None;
self.active_run_id = None;
}
}
@@ -739,7 +789,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// The interceptor governs approval, skip, pause, and abort decisions
/// at key points in the execution loop. If not set, the default
/// interceptor is used (all Continue / Finish).
pub fn set_interceptor(&mut self, interceptor: impl Interceptor + 'static) {
pub fn set_interceptor(&mut self, interceptor: impl Interceptor<A> + 'static) {
self.interceptor = Box::new(interceptor);
}
@@ -840,6 +890,10 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
///
/// `Some` is retained only while Pause or Yield permits a later
/// [`resume`](Self::resume). Terminal outcomes return this to `None`.
pub fn last_run_exit_observer_failure(&self) -> Option<&InterceptorFailure> {
self.last_run_exit_observer_failure.as_ref()
}
pub fn active_run_turn_count(&self) -> Option<usize> {
self.active_run_turn_count
}
@@ -851,6 +905,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
/// [`resume`](Self::resume) starts a fresh budget.
pub fn set_active_run_turn_count(&mut self, turn_count: Option<usize>) {
self.active_run_turn_count = turn_count;
if turn_count.is_none() {
self.active_run_id = None;
} else if self.active_run_id.is_none() {
self.active_run_id = Some(InterceptorRunId(self.next_run_id));
self.next_run_id = self.next_run_id.wrapping_add(1).max(1);
self.interceptor_invocation_count = 0;
}
}
/// Get the current LlmCall count (per-Engine running counter, never
@@ -1076,24 +1137,28 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
request
}
/// Hooks: on_prompt_submit
///
async fn finalize_interruption<T>(
async fn finalize_run_exit(
&mut self,
result: Result<T, EngineError>,
) -> Result<T, EngineError> {
match result {
Ok(value) => Ok(value),
Err(err) => {
let reason = match &err {
EngineError::Aborted(reason) => reason.clone(),
EngineError::Cancelled => "Cancelled".to_string(),
_ => err.to_string(),
};
self.interceptor.on_abort(&reason).await;
Err(err)
}
history: &History<A>,
result: Result<EngineResult, EngineError>,
) -> EngineRunExit {
let exit = EngineRunExit::from(result);
let invocation = self.interceptor_invocation(InterceptorPhase::RunExit, None, None, 0);
self.last_run_exit_observer_failure = None;
if let Err(error) = self
.interceptor
.on_run_exit(RunExitContext {
invocation,
exit: &exit,
history: history.entries(),
})
.await
{
self.last_run_exit_observer_failure =
Some(InterceptorFailure::new(InterceptorPhase::RunExit, error));
}
self.finish_logical_run(&exit);
exit
}
/// Check for pending tool calls (for resuming from Pause)
@@ -1164,21 +1229,60 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
let mut approved_calls = Vec::new();
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
let expected_tool_use_id = tool_call.id.clone();
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
if let Some((meta, tool)) = self.tool_server.get_tool(&tool_call.name) {
let invocation = self.interceptor_invocation(
InterceptorPhase::PreToolCall,
Some(self.turn_count.saturating_sub(1)),
Some(InterceptorCallId::Tool(expected_tool_use_id.clone())),
call_index,
);
let mut info = ToolCallInfo {
invocation,
history: history.entries(),
call: tool_call.clone(),
meta,
tool,
context,
};
match self.interceptor.pre_tool_call(&mut info).await {
let pre_tool_action =
self.interceptor
.pre_tool_call(&mut info)
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
error,
))
})?;
if info.call.id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"pre-tool interceptor changed immutable tool call identity",
),
)
.into());
}
match pre_tool_action {
PreToolAction::Continue => {}
PreToolAction::Skip => {
continue;
}
PreToolAction::SyntheticResult(result) => {
if result.tool_use_id != expected_tool_use_id {
return Err(InterceptorFailure::new(
InterceptorPhase::PreToolCall,
InterceptorError::new(
InterceptorErrorCategory::ContractViolation,
"synthetic tool result changed immutable tool call identity",
),
)
.into());
}
let tool_call = info.call;
let mut context = info.context;
context.call_id = tool_call.id.clone();
@@ -1285,20 +1389,31 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let mut terminal_call_ids = HashSet::new();
let mut pause_requested = false;
let mut pause_deadline = None;
let mut batch_error = None;
let mut locally_enqueued_cancel = false;
for result in synthetic_results {
self.finalize_and_commit_tool_result(
history,
annotate,
result,
None,
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
)
.await?;
if let Err(error) = self
.finalize_and_commit_tool_result(
history,
annotate,
result,
None,
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
)
.await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
let mut futures = futures;
if batch_error.is_some() && !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
while !futures.is_empty() {
tokio::select! {
// If cancellation and a completed result are both ready, drain
@@ -1308,7 +1423,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => {
let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
result,
@@ -1316,7 +1431,15 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await {
if batch_error.is_none() {
batch_error = Some(error);
}
if !futures.is_empty() {
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
}
}
pause = self.pause_rx.recv(), if !pause_requested => {
if pause.is_some() {
@@ -1333,6 +1456,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
pause_deadline = None;
let _ = self.cancel_tx.try_send(());
locally_enqueued_cancel = true;
}
cancel = self.cancel_rx.recv() => {
if cancel.is_some() {
@@ -1378,7 +1502,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
result = futures.next() => {
let (attempt_id, result) =
result.expect("non-empty FuturesUnordered returns a result");
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
result,
@@ -1386,7 +1510,11 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
_ = tokio::time::sleep_until(deadline) => break,
}
@@ -1400,7 +1528,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
if let Some(handle) = execution_handles.get(call_id) {
handle.force_close();
}
self.finalize_and_commit_tool_result(
if let Err(error) = self.finalize_and_commit_tool_result(
history,
annotate,
ToolResult::outcome_unknown(call_id),
@@ -1408,11 +1536,18 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
&call_info_map,
&mut attempt_fence,
&mut terminal_call_ids,
).await?;
).await
&& batch_error.is_none()
{
batch_error = Some(error);
}
}
}
self.timeline.abort_current_block();
if let Some(error) = batch_error.take() {
return Err(error);
}
if pause_requested {
return Ok(ToolExecutionResult::Paused);
}
@@ -1421,6 +1556,16 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
}
// A result-biased ready sibling can empty the batch before the local
// cancel signal is selected. Never let that current-batch signal leak
// into the next run or resume call.
if locally_enqueued_cancel {
let _ = self.cancel_rx.try_recv();
}
if let Some(error) = batch_error {
self.timeline.abort_current_block();
return Err(error);
}
Ok(if pause_requested {
ToolExecutionResult::Paused
} else {
@@ -1464,31 +1609,13 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
let call_info = call_info_map.get(&tool_result.tool_use_id);
let mut abort_reason = None;
if let Some((tool_call, meta, tool, context)) = call_info {
let mut info = ToolResultInfo {
call: tool_call.clone(),
result: tool_result,
meta: meta.clone(),
tool: tool.clone(),
context: context.clone(),
};
match self.interceptor.post_tool_call(&mut info).await {
PostToolAction::Continue => {}
PostToolAction::Abort(reason) => {
abort_reason = Some(reason);
}
}
tool_result = info.result;
}
if tool_result.is_error && tool_result.disposition.is_success() {
tool_result.disposition = ToolResultDisposition::Error;
}
tool_result.is_error = !tool_result.disposition.is_success();
// Cap content only after post_tool_call so interceptors still observe
// the full payload and any content they inject is bounded too.
// Bound the terminal payload before committing it so the post-tool
// interceptor observes exactly the model-visible durable result.
if let (Some(limits), Some((tool_call, _, _, _)), Some(content)) = (
self.tool_output_limits.as_ref(),
call_info,
@@ -1541,9 +1668,38 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
"Tool execution terminalized"
);
self.emit_tool_result(&tool_result);
if let Some(reason) = abort_reason {
return Err(EngineError::Aborted(reason));
if let Some((tool_call, meta, tool, context)) = call_info {
let invocation = self.interceptor_invocation(
InterceptorPhase::PostToolCall,
Some(self.turn_count.saturating_sub(1)),
Some(InterceptorCallId::Tool(tool_call.id.clone())),
context.call_index,
);
let info = ToolResultInfo {
invocation,
history: history.entries(),
call: tool_call.clone(),
result: tool_result,
meta: meta.clone(),
tool: tool.clone(),
context: context.clone(),
};
let post_tool_action =
self.interceptor
.post_tool_call(&info)
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PostToolCall,
error,
))
})?;
if let PostToolAction::Abort(reason) = post_tool_action {
return Err(EngineError::Aborted(reason));
}
}
Ok(true)
}
@@ -1606,11 +1762,25 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
// These are committed *before* the per-request clone so they
// participate in the LLM request below and get persisted by
// the caller that owns durable history.
let pending_invocation = self.interceptor_invocation(
InterceptorPhase::PendingHistoryAppends,
Some(current_turn),
None,
0,
);
let pending = self
.interceptor
.pending_history_appends()
.pending_history_appends(PendingHistoryAppendsContext {
invocation: pending_invocation,
history: history.entries(),
})
.await
.map_err(EngineError::HistoryAppend)?;
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PendingHistoryAppends,
error,
))
})?;
if !pending.is_empty() {
self.append_history_items(history, pending, annotate)?;
}
@@ -1677,7 +1847,27 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
// Interceptor: pre_llm_request
match self.interceptor.pre_llm_request(&mut request_context).await {
let request_invocation = self.interceptor_invocation(
InterceptorPhase::PreLlmRequest,
Some(current_turn),
Some(InterceptorCallId::Llm(self.llm_call_count as u64)),
0,
);
let pre_request_action = self
.interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: request_invocation,
items: &mut request_context,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PreLlmRequest,
error,
))
})?;
match pre_request_action {
PreRequestAction::Cancel(reason) => {
info!(reason = %reason, "Aborted by interceptor");
for cb in &self.turn_end_cbs {
@@ -1789,21 +1979,45 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
let tool_calls = self.tool_call_collector.take_collected();
let assistant_items =
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
let assistant_start = history.len();
self.append_history_items(history, assistant_items, annotate)?;
if tool_calls.is_empty() {
let turn_end_context = history.items_cloned();
match self.interceptor.on_turn_end(&turn_end_context).await {
TurnEndAction::Finish => {
return Ok(EngineResult::Finished);
}
TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(history, additional, annotate)?;
let assistant_invocation = self.interceptor_invocation(
InterceptorPhase::AssistantTurnEnd,
Some(current_turn),
Some(InterceptorCallId::Llm(
self.llm_call_count.saturating_sub(1) as u64,
)),
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
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::AssistantTurnEnd,
error,
))
})?;
match assistant_turn_action {
TurnEndAction::Finish if tool_calls.is_empty() => {
return Ok(EngineResult::Finished);
}
TurnEndAction::Finish => {}
TurnEndAction::ContinueWithMessages(additional) => {
self.append_history_items(history, additional, annotate)?;
if tool_calls.is_empty() {
continue;
}
TurnEndAction::Pause => {
return Ok(EngineResult::Paused);
}
}
TurnEndAction::Pause => {
return Ok(EngineResult::Paused);
}
}
@@ -2096,7 +2310,7 @@ impl<C: LlmClient, S: EngineState, A> Engine<C, S, A> {
}
}
impl<C: LlmClient, A> Engine<C, Mutable, A> {
impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
/// Create a new annotated Engine (in Mutable state).
pub fn new_annotated(client: C) -> Self {
let text_block_collector = TextBlockCollector::new();
@@ -2124,6 +2338,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len: 0,
turn_count: 0,
active_run_turn_count: None,
active_run_id: None,
next_run_id: 1,
interceptor_invocation_count: 0,
last_run_exit_observer_failure: None,
llm_call_count: 0,
tool_execution_batch_count: 0,
max_turns: None,
@@ -2399,6 +2617,10 @@ impl<C: LlmClient, A> Engine<C, Mutable, A> {
locked_prefix_len,
turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns,
@@ -2475,7 +2697,7 @@ impl<C: LlmClient> Engine<C, Mutable, ()> {
}
}
impl<C: LlmClient, A> Engine<C, Locked, A> {
impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
/// Execute a turn
///
/// Adds a new user message to history and sends a request to the LLM.
@@ -2486,9 +2708,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
user_input: impl Into<String>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit {
self.run_result_with_annotation(history, user_input.into(), annotate)
.await
.into()
let result = self
.run_result_with_annotation(history, user_input.into(), annotate)
.await;
self.finalize_run_exit(history, result).await
}
async fn run_result_with_annotation(
@@ -2499,13 +2722,26 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
) -> Result<EngineResult, EngineError> {
// Supplying new user input abandons any paused/yielded logical run.
self.active_run_turn_count = None;
self.active_run_id = None;
self.start_logical_run();
let mut user_item = Item::user_message(user_input);
let extras = match self.interceptor.on_prompt_submit(&mut user_item).await {
PromptAction::Cancel(reason) => {
return self
.finalize_interruption(Err(EngineError::Aborted(reason)))
.await;
}
let invocation = self.interceptor_invocation(InterceptorPhase::PromptSubmit, None, None, 0);
let prompt_action = self
.interceptor
.on_prompt_submit(PromptSubmitContext {
invocation,
item: &mut user_item,
history: history.entries(),
})
.await
.map_err(|error| {
EngineError::from(InterceptorFailure::new(
InterceptorPhase::PromptSubmit,
error,
))
})?;
let extras = match prompt_action {
PromptAction::Cancel(reason) => return Err(EngineError::Aborted(reason)),
PromptAction::Continue => Vec::new(),
PromptAction::ContinueWith(items) => items,
};
@@ -2513,14 +2749,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
if !extras.is_empty() {
self.append_history_items(history, extras, annotate)?;
}
self.start_logical_run();
let result = match self.run_turn_loop(history, annotate).await {
match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other,
};
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
}
}
/// Resume execution (from Paused state).
@@ -2529,9 +2761,8 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
history: &mut History<A>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> EngineRunExit {
self.resume_result_with_annotation(history, annotate)
.await
.into()
let result = self.resume_result_with_annotation(history, annotate).await;
self.finalize_run_exit(history, result).await
}
async fn resume_result_with_annotation(
@@ -2540,13 +2771,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<EngineResult, EngineError> {
self.ensure_logical_run();
let result = match self.run_turn_loop(history, annotate).await {
match self.run_turn_loop(history, annotate).await {
Err(EngineError::PauseRequested) => Ok(EngineResult::Paused),
other => other,
};
let result = self.finalize_interruption(result).await;
self.finish_logical_run(&result);
result
}
}
/// Get the prefix length at lock time
@@ -2572,6 +2800,10 @@ impl<C: LlmClient, A> Engine<C, Locked, A> {
locked_prefix_len: 0,
turn_count: self.turn_count,
active_run_turn_count: self.active_run_turn_count,
active_run_id: self.active_run_id,
next_run_id: self.next_run_id,
interceptor_invocation_count: self.interceptor_invocation_count,
last_run_exit_observer_failure: self.last_run_exit_observer_failure,
llm_call_count: self.llm_call_count,
tool_execution_batch_count: self.tool_execution_batch_count,
max_turns: self.max_turns,
+250 -28
View File
@@ -9,8 +9,202 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::Item;
use crate::engine::EngineRunExit;
use crate::history::HistoryEntry;
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
// =============================================================================
// Typed lifecycle metadata and failures
// =============================================================================
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
/// Stable category for the source of an interceptor failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterceptorErrorCategory {
Policy,
Dependency,
ContractViolation,
Internal,
}
impl std::fmt::Display for InterceptorErrorCategory {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Policy => "policy",
Self::Dependency => "dependency",
Self::ContractViolation => "contract_violation",
Self::Internal => "internal",
})
}
}
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{category}: {diagnostic}")]
pub struct InterceptorError {
category: InterceptorErrorCategory,
diagnostic: String,
}
impl InterceptorError {
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
let mut diagnostic = diagnostic.into();
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
while !diagnostic.is_char_boundary(end) {
end -= 1;
}
diagnostic.truncate(end);
}
Self {
category,
diagnostic,
}
}
pub fn category(&self) -> InterceptorErrorCategory {
self.category
}
pub fn diagnostic(&self) -> &str {
&self.diagnostic
}
}
/// The lifecycle phase at which an interceptor callback executes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InterceptorPhase {
#[default]
PromptSubmit,
PendingHistoryAppends,
PreLlmRequest,
PreToolCall,
PostToolCall,
AssistantTurnEnd,
RunExit,
}
impl std::fmt::Display for InterceptorPhase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::PromptSubmit => "prompt_submit",
Self::PendingHistoryAppends => "pending_history_appends",
Self::PreLlmRequest => "pre_llm_request",
Self::PreToolCall => "pre_tool_call",
Self::PostToolCall => "post_tool_call",
Self::AssistantTurnEnd => "assistant_turn_end",
Self::RunExit => "run_exit",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct InterceptorRunId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct InterceptorTurnId(pub u64);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum InterceptorCallId {
Llm(u64),
Tool(String),
}
/// Saturating public counter used by interceptor contexts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct InterceptorCounter(u32);
impl InterceptorCounter {
pub fn from_usize(value: usize) -> Self {
Self(u32::try_from(value).unwrap_or(u32::MAX))
}
pub fn get(self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InterceptorCounters {
pub invocation: InterceptorCounter,
pub engine_turn: InterceptorCounter,
pub run_turn: InterceptorCounter,
pub llm_call: InterceptorCounter,
pub tool_batch: InterceptorCounter,
pub tool_call: InterceptorCounter,
}
/// Identity, phase, and bounded counters common to every lifecycle callback.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InterceptorInvocation {
pub run_id: InterceptorRunId,
pub turn_id: Option<InterceptorTurnId>,
pub call_id: Option<InterceptorCallId>,
pub phase: InterceptorPhase,
pub counters: InterceptorCounters,
}
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{phase} interceptor failed: {error}")]
pub struct InterceptorFailure {
phase: InterceptorPhase,
#[source]
error: InterceptorError,
}
impl InterceptorFailure {
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
Self { phase, error }
}
pub fn phase(&self) -> InterceptorPhase {
self.phase
}
pub fn error(&self) -> &InterceptorError {
&self.error
}
}
pub type InterceptorResult<T> = Result<T, InterceptorError>;
// =============================================================================
// Lifecycle Contexts
// =============================================================================
pub struct PromptSubmitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub item: &'a mut Item,
pub history: &'a [HistoryEntry<A>],
}
pub struct PendingHistoryAppendsContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
}
pub struct PreLlmRequestContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub items: &'a mut Vec<Item>,
pub history: &'a [HistoryEntry<A>],
}
pub struct AssistantTurnEndContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub assistant_entries: &'a [HistoryEntry<A>],
pub history: &'a [HistoryEntry<A>],
pub tool_calls: &'a [ToolCall],
}
pub struct RunExitContext<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub exit: &'a EngineRunExit,
pub history: &'a [HistoryEntry<A>],
}
// =============================================================================
// Action Enums
// =============================================================================
@@ -86,9 +280,9 @@ pub enum PostToolAction {
/// Action at the end of a turn (when LLM produces no tool calls).
#[derive(Debug, Clone)]
pub enum TurnEndAction {
/// Turn is finished, return to caller.
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
Finish,
/// Continue with additional messages injected into history.
/// Commit additional messages, then continue through the natural next phase.
ContinueWithMessages(Vec<Item>),
/// Pause execution (can be resumed later).
Pause,
@@ -99,8 +293,9 @@ pub enum TurnEndAction {
// =============================================================================
/// Context for pre-tool-call decisions.
pub struct ToolCallInfo {
/// Tool call information (modifiable).
pub struct ToolCallInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool meta information.
pub meta: ToolMeta,
@@ -111,10 +306,11 @@ pub struct ToolCallInfo {
}
/// Context for post-tool-call decisions.
pub struct ToolResultInfo {
/// Original tool call.
pub struct ToolResultInfo<'a, A = ()> {
pub invocation: InterceptorInvocation,
pub history: &'a [HistoryEntry<A>],
pub call: ToolCall,
/// Tool execution result (modifiable).
/// Committed terminal tool execution result.
pub result: ToolResult,
/// Tool meta information.
pub meta: ToolMeta,
@@ -130,14 +326,22 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points.
///
/// All methods have default implementations that let the Engine
/// proceed without intervention. Callers provide richer implementations for
/// approval flows, permission checks, etc.
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
/// keeping implementation failure separate from the method's control-flow
/// action. The Engine reports a failure as a typed run interruption annotated
/// with the exact [`InterceptorPhase`] that failed.
///
/// All methods have default implementations that let the Engine proceed
/// without intervention. Callers provide richer implementations for approval
/// flows, permission checks, and other trusted host adaptation.
#[async_trait]
pub trait Interceptor: Send + Sync {
/// Called after receiving user input, before adding to history.
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
PromptAction::Continue
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
/// Called after receiving user input, before adding it to Engine history.
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, A>,
) -> InterceptorResult<PromptAction> {
Ok(PromptAction::Continue)
}
/// Items that should be **committed to `engine.history`** just
@@ -158,7 +362,10 @@ pub trait Interceptor: Send + Sync {
/// reproducible per-request transformations (pruning, content
/// trimming, cache anchors) that depend only on the existing
/// history.
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, A>,
) -> InterceptorResult<Vec<Item>> {
Ok(Vec::new())
}
@@ -170,27 +377,42 @@ pub trait Interceptor: Send + Sync {
/// If an interceptor derives a human/model-visible nudge from the current
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
/// commits it to history before the request is sent.
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
PreRequestAction::Continue
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
Ok(PreRequestAction::Continue)
}
/// Called before each tool is executed.
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::Continue
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::Continue)
}
/// Called after each tool completes.
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Continue
/// Called after each tool reaches one terminal result and that result is committed.
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, A>,
) -> InterceptorResult<PostToolAction> {
Ok(PostToolAction::Continue)
}
/// Called when a turn ends with no tool calls.
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
TurnEndAction::Finish
/// Called after every terminal assistant response is committed and before
/// the Engine decides whether to execute tools, continue, or finish.
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, A>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Finish)
}
/// Called when execution is interrupted (abort or cancel).
async fn on_abort(&self, _reason: &str) {}
/// Called once for the terminal outcome of each public run or resume call.
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
Ok(())
}
}
/// Default interceptor: no intervention. Engine proceeds through the loop
@@ -198,4 +420,4 @@ pub trait Interceptor: Send + Sync {
pub(crate) struct DefaultInterceptor;
#[async_trait]
impl Interceptor for DefaultInterceptor {}
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
+8 -2
View File
@@ -22,11 +22,17 @@ pub use agen_macros::{description, tool, tool_registry};
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
pub use engine::{
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
LlmRetryNotice, StopReason, ToolRegistryError,
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
};
pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor;
pub use interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
};
pub use message::{ContentPart, Item, Message, Role};
pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
+126
View File
@@ -1,8 +1,15 @@
mod common;
use agen::interceptor::{
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use async_trait::async_trait;
use common::MockLlmClient;
use std::sync::{Arc, Mutex};
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
@@ -47,6 +54,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
assert_eq!(history.items_cloned().len(), 2);
}
#[derive(Clone)]
struct AnnotationObservingInterceptor {
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
}
impl AnnotationObservingInterceptor {
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
self.observed.lock().unwrap().push((
invocation.clone(),
history
.iter()
.map(|entry| entry.annotation.clone())
.collect(),
));
}
}
#[async_trait]
impl Interceptor<String> for AnnotationObservingInterceptor {
async fn on_prompt_submit(
&self,
context: PromptSubmitContext<'_, String>,
) -> InterceptorResult<PromptAction> {
self.record(&context.invocation, context.history);
Ok(PromptAction::Continue)
}
async fn pending_history_appends(
&self,
context: PendingHistoryAppendsContext<'_, String>,
) -> InterceptorResult<Vec<Item>> {
self.record(&context.invocation, context.history);
Ok(Vec::new())
}
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, String>,
) -> InterceptorResult<PreRequestAction> {
self.record(&context.invocation, context.history);
Ok(PreRequestAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, String>,
) -> InterceptorResult<TurnEndAction> {
assert_eq!(context.assistant_entries.len(), 1);
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
self.record(&context.invocation, context.history);
Ok(TurnEndAction::Finish)
}
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
self.record(&context.invocation, context.history);
Ok(())
}
}
#[tokio::test]
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let observed = Arc::new(Mutex::new(Vec::new()));
engine.set_interceptor(AnnotationObservingInterceptor {
observed: observed.clone(),
});
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = if item.is_assistant_message() {
"assistant"
} else {
"user"
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
let observed = observed.lock().unwrap();
let phases: Vec<_> = observed
.iter()
.map(|(invocation, _)| invocation.phase)
.collect();
assert_eq!(
phases,
[
InterceptorPhase::PromptSubmit,
InterceptorPhase::PendingHistoryAppends,
InterceptorPhase::PreLlmRequest,
InterceptorPhase::AssistantTurnEnd,
InterceptorPhase::RunExit,
]
);
assert!(
observed
.iter()
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
);
assert_eq!(
observed
.iter()
.map(|(invocation, _)| invocation.counters.invocation.get())
.collect::<Vec<_>>(),
[0, 1, 2, 3, 4]
);
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
assert_eq!(observed[1].1, ["1:user"]);
assert_eq!(observed[2].1, ["1:user"]);
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
+524 -15
View File
@@ -10,11 +10,18 @@ use std::sync::{Arc, Mutex};
use agen::Item;
use agen::interceptor::{
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
TurnEndAction,
};
use agen::llm_client::{
ClientError, LlmClient, Request, ResponseStream,
event::{Event, ResponseStatus, StatusEvent},
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
use async_trait::async_trait;
use common::MockLlmClient;
@@ -205,7 +212,7 @@ async fn history_append_failure_stops_before_tool_execution() {
let exit = engine.run(&mut history, "use the tool").await;
assert!(
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
matches!(exit, EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
);
assert_eq!(tool.call_count(), 0);
assert_eq!(history.len(), 1);
@@ -613,12 +620,15 @@ struct YieldOnce {
#[async_trait]
impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
}
})
}
}
@@ -628,12 +638,15 @@ struct PauseToolOnce {
#[async_trait]
impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause
} else {
PreToolAction::Continue
}
})
}
}
@@ -643,13 +656,509 @@ struct ContinueTurnOnce {
#[async_trait]
impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else {
TurnEndAction::Finish
})
}
}
#[derive(Debug, Clone)]
struct FailingLifecycleInterceptor {
failure: InterceptorPoint,
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
}
impl FailingLifecycleInterceptor {
fn new(failure: InterceptorPoint) -> Self {
Self {
failure,
calls: Arc::new(Mutex::new(Vec::new())),
}
}
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
self.calls.lock().unwrap().push(point);
if self.failure == point {
Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
format!("{point} rejected"),
))
} else {
Ok(action)
}
}
fn calls(&self) -> Vec<InterceptorPoint> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for FailingLifecycleInterceptor {
async fn on_prompt_submit(
&self,
_context: PromptSubmitContext<'_, ()>,
) -> InterceptorResult<PromptAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
}
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, ()>,
) -> InterceptorResult<Vec<Item>> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
}
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
}
async fn pre_tool_call(
&self,
_info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
}
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
tokio::task::yield_now().await;
assert!(context.history.ends_with(context.assistant_entries));
if !context.tool_calls.is_empty() {
assert_eq!(
context
.assistant_entries
.iter()
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
.count(),
context.tool_calls.len()
);
}
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
tokio::task::yield_now().await;
self.record(InterceptorPoint::RunExit, ())
}
}
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
use InterceptorPoint as Point;
let mut calls = match failure {
Point::PromptSubmit => vec![Point::PromptSubmit],
Point::PendingHistoryAppends => {
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
}
Point::PreLlmRequest => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
],
Point::PreToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
],
Point::PostToolCall => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
Point::PreToolCall,
Point::PostToolCall,
],
Point::AssistantTurnEnd => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
Point::RunExit => vec![
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::AssistantTurnEnd,
],
};
calls.push(Point::RunExit);
calls
}
#[tokio::test]
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
use InterceptorPoint as Point;
for failure_point in [
Point::PromptSubmit,
Point::PendingHistoryAppends,
Point::PreLlmRequest,
Point::PreToolCall,
Point::PostToolCall,
Point::AssistantTurnEnd,
Point::RunExit,
] {
let interceptor = FailingLifecycleInterceptor::new(failure_point);
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
let events = if needs_tool {
vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
} else {
completed_text_events()
};
let mut engine = Engine::new(MockLlmClient::new(events));
engine.register_tool(CountingTool::new("count_tool").definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
let exit = engine.run(&mut history, "test").await;
let failure = if failure_point == Point::RunExit {
assert!(matches!(exit, EngineRunExit::Finished));
engine
.last_run_exit_observer_failure()
.expect("terminal observer diagnostic should be retained")
} else {
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = &exit
else {
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
};
failure
};
assert_eq!(failure.phase(), failure_point);
assert_eq!(
failure.error().diagnostic(),
format!("{failure_point} rejected")
);
assert_eq!(
interceptor.calls(),
expected_interceptor_calls(failure_point)
);
if failure_point == Point::PostToolCall {
assert!(
history
.items()
.any(|item| matches!(item, Item::ToolResult { .. })),
"post-tool failure must not precede terminal output commit"
);
}
}
}
#[test]
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
let error = InterceptorError::new(
InterceptorErrorCategory::Dependency,
"".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
);
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
assert!(
error
.diagnostic()
.is_char_boundary(error.diagnostic().len())
);
}
struct FailingRunExitObserver {
pause: bool,
}
#[async_trait]
impl Interceptor for FailingRunExitObserver {
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
Ok(if self.pause {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
"terminal audit unavailable",
))
}
}
#[tokio::test]
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
let mut paused_history = History::new();
let mut paused_engine = paused_engine.lock(&paused_history);
assert!(matches!(
paused_engine.run(&mut paused_history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(
paused_engine
.last_run_exit_observer_failure()
.expect("paused observer diagnostic")
.error()
.category(),
InterceptorErrorCategory::Dependency
);
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
interrupted_engine.set_max_turns(Some(0));
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
let mut interrupted_history = History::new();
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
assert!(matches!(
interrupted_engine
.run(&mut interrupted_history, "limit")
.await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(
interrupted_engine
.last_run_exit_observer_failure()
.expect("interrupted observer diagnostic")
.phase(),
InterceptorPoint::RunExit
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalMode {
Finish,
PauseOnce,
Yield,
}
#[derive(Debug, Clone)]
struct RecordingTerminalInterceptor {
mode: TerminalMode,
assistant_turns: Arc<AtomicUsize>,
exits: Arc<Mutex<Vec<&'static str>>>,
}
impl RecordingTerminalInterceptor {
fn new(mode: TerminalMode) -> Self {
Self {
mode,
assistant_turns: Arc::new(AtomicUsize::new(0)),
exits: Arc::new(Mutex::new(Vec::new())),
}
}
fn exits(&self) -> Vec<&'static str> {
self.exits.lock().unwrap().clone()
}
}
#[async_trait]
impl Interceptor for RecordingTerminalInterceptor {
async fn pre_llm_request(
&self,
_context: PreLlmRequestContext<'_, ()>,
) -> InterceptorResult<PreRequestAction> {
Ok(if self.mode == TerminalMode::Yield {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
})
}
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, ()>,
) -> InterceptorResult<TurnEndAction> {
assert!(!context.assistant_entries.is_empty());
assert!(
context.history.ends_with(context.assistant_entries),
"assistant-turn callback must observe committed terminal items"
);
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
TurnEndAction::Pause
} else {
TurnEndAction::Finish
})
}
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
let kind = match context.exit {
EngineRunExit::Finished => "finished",
EngineRunExit::Paused => "paused",
EngineRunExit::Yielded => "yielded",
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
};
self.exits.lock().unwrap().push(kind);
Ok(())
}
}
#[derive(Clone)]
struct ContextWindowClient;
#[async_trait]
impl LlmClient for ContextWindowClient {
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
Err(ClientError::ContextWindowExceeded)
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
#[tokio::test]
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(finished.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "finish").await,
EngineRunExit::Finished
));
assert_eq!(finished.exits(), ["finished"]);
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(yielded.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "yield").await,
EngineRunExit::Yielded
));
assert_eq!(yielded.exits(), ["yielded"]);
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(0));
engine.set_interceptor(limited.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "limit").await,
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(limited.exits(), ["limit"]);
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(cancelled.clone());
engine.cancel();
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "cancel").await,
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
));
assert_eq!(cancelled.exits(), ["cancelled"]);
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
let mut engine = Engine::new(ContextWindowClient);
engine.set_interceptor(context.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "context").await,
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
));
assert_eq!(context.exits(), ["context"]);
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_interceptor(unexpected.clone());
let mut history = History::new();
assert!(matches!(
engine.lock(&history).run(&mut history, "fail").await,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
_
)))
));
assert_eq!(
unexpected
.calls()
.iter()
.filter(|point| **point == InterceptorPoint::RunExit)
.count(),
1
);
}
#[tokio::test]
async fn terminal_observer_does_not_duplicate_on_resume() {
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
let first_response = vec![
Event::tool_use_start(0, "call-1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
engine.register_tool(tool.definition());
engine.set_interceptor(interceptor.clone());
let mut history = History::new();
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(interceptor.exits(), ["paused"]);
assert_eq!(
tool.call_count(),
0,
"pause must retain the pending tool phase"
);
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Finished
));
assert_eq!(interceptor.exits(), ["paused", "finished"]);
assert_eq!(
tool.call_count(),
1,
"resume must execute the retained tool once"
);
}
#[tokio::test]
@@ -730,7 +1239,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
@@ -785,7 +1294,7 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
assert!(matches!(
engine.run(&mut history, "start").await,
EngineRunExit::Interrupted(StopReason::LimitReached)
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.llm_call_count(), 1);
@@ -803,7 +1312,7 @@ async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
));
assert_eq!(engine.turn_count(), 7);
assert_eq!(engine.llm_call_count(), 0);
+242 -42
View File
@@ -6,13 +6,18 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
use agen::interceptor::{
Interceptor, InterceptorError, InterceptorErrorCategory, InterceptorPhase, InterceptorResult,
PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition,
};
use agen::{Engine, History, Item, ToolExecutionPolicy};
use agen::{
Engine, EngineError, EngineRunExit, History, Item, RunInterruptionReason, ToolExecutionPolicy,
};
use async_trait::async_trait;
mod common;
@@ -580,7 +585,7 @@ async fn cooperative_cancellation_commits_bounded_terminal_output() {
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::Cancelled)
));
}
@@ -905,24 +910,30 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
#[async_trait]
impl Interceptor for ContextPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
self.pre_contexts.lock().unwrap().push(info.context.clone());
match info.call.name.as_str() {
Ok(match info.call.name.as_str() {
"skip_tool" => PreToolAction::Skip,
"synthetic_tool" => PreToolAction::SyntheticResult(ToolResult::from_output(
&info.call.id,
ToolOutput::from("synthetic result".to_string()),
)),
_ => PreToolAction::Continue,
}
})
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.post_contexts
.lock()
.unwrap()
.push(info.context.clone());
PostToolAction::Continue
Ok(PostToolAction::Continue)
}
}
@@ -994,12 +1005,15 @@ async fn test_before_tool_call_skip() {
#[async_trait]
impl Interceptor for BlockingPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
if info.call.name == "blocked_tool" {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(if info.call.name == "blocked_tool" {
PreToolAction::Skip
} else {
PreToolAction::Continue
}
})
}
}
@@ -1021,9 +1035,9 @@ async fn test_before_tool_call_skip() {
);
}
/// Hook: post_tool_call - verify that results can be modified
/// Hook: post_tool_call - verify that the committed terminal result is observed.
#[tokio::test]
async fn test_post_tool_call_modification() {
async fn test_post_tool_call_observes_committed_result() {
// Prepare responses for multiple requests
let client = MockLlmClient::with_responses(vec![
// First request: tool call
@@ -1074,40 +1088,51 @@ async fn test_post_tool_call_modification() {
engine.register_tool(simple_tool_definition());
// Policy to modify results
struct ModifyingPolicy {
modified_content: Arc<std::sync::Mutex<Option<String>>>,
// Policy to observe the committed terminal result.
struct ObservingPolicy {
observed_content: Arc<std::sync::Mutex<Option<String>>>,
}
#[async_trait]
impl Interceptor for ModifyingPolicy {
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
info.result.summary = format!("[Modified] {}", info.result.summary);
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
PostToolAction::Continue
impl Interceptor for ObservingPolicy {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PostToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool(info.call.id.clone()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolResult { call_id, .. }) if call_id == &info.call.id
));
*self.observed_content.lock().unwrap() = Some(info.result.summary.clone());
Ok(PostToolAction::Continue)
}
}
let modified_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ModifyingPolicy {
modified_content: modified_content.clone(),
let observed_content = Arc::new(std::sync::Mutex::new(None));
engine.set_interceptor(ObservingPolicy {
observed_content: observed_content.clone(),
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run(&mut history, "Test modification").await;
let result = engine.run(&mut history, "Test observation").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
// Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone();
assert!(content.is_some(), "Hook should have been called");
assert!(
content.unwrap().contains("[Modified]"),
"Result should be modified"
);
// Verify the interceptor observed the exact committed result.
let observed = observed_content.lock().unwrap().clone();
assert_eq!(observed.as_deref(), Some("Original Result"));
assert!(history.items().any(|item| matches!(
item,
Item::ToolResult { summary, .. } if summary == "Original Result"
)));
}
/// Hook: pre_tool_call synthetic result - skipped tool gets an error result in history.
@@ -1143,11 +1168,14 @@ async fn test_before_tool_call_synthetic_result_committed() {
#[async_trait]
impl Interceptor for SyntheticPolicy {
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
PreToolAction::SyntheticResult(ToolResult::error(
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(),
"permission denied",
))
)))
}
}
@@ -1167,6 +1195,80 @@ async fn test_before_tool_call_synthetic_result_committed() {
)));
}
#[derive(Clone, Copy)]
enum InvalidIdentityMode {
ContinuedCall,
SyntheticResult,
}
struct InvalidIdentityPolicy(InvalidIdentityMode);
#[async_trait]
impl Interceptor for InvalidIdentityPolicy {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, ()>,
) -> InterceptorResult<PreToolAction> {
assert_eq!(info.invocation.phase, InterceptorPhase::PreToolCall);
assert_eq!(
info.invocation.call_id,
Some(agen::InterceptorCallId::Tool("call_1".to_string()))
);
assert!(matches!(
info.history.last().map(|entry| &entry.item),
Some(Item::ToolCall { call_id, .. }) if call_id == "call_1"
));
Ok(match self.0 {
InvalidIdentityMode::ContinuedCall => {
info.call.id = "different-call".to_string();
PreToolAction::Continue
}
InvalidIdentityMode::SyntheticResult => PreToolAction::SyntheticResult(
ToolResult::error("different-call", "invalid synthetic result"),
),
})
}
}
#[tokio::test]
async fn interceptor_cannot_change_tool_call_identity() {
for mode in [
InvalidIdentityMode::ContinuedCall,
InvalidIdentityMode::SyntheticResult,
] {
let client = MockLlmClient::new(vec![
Event::tool_use_start(0, "call_1", "echo"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("echo", 1).definition());
engine.set_interceptor(InvalidIdentityPolicy(mode));
let mut history = History::new();
let result = engine.run(&mut history, "identity").await;
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(failure),
)) = result.result
else {
panic!("invalid tool identity must interrupt with a typed failure");
};
assert_eq!(failure.phase(), InterceptorPhase::PreToolCall);
assert_eq!(
failure.error().category(),
InterceptorErrorCategory::ContractViolation
);
assert!(
!history
.items()
.any(|item| matches!(item, Item::ToolResult { .. }))
);
}
}
#[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![
@@ -1181,16 +1283,24 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition());
struct AbortAfterResult;
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
struct AbortAfterResult {
lifecycle: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Abort("policy stopped the run".to_string())
async fn post_tool_call(
&self,
_info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
self.lifecycle.lock().unwrap().push("post_tool_call");
Ok(PostToolAction::Abort("policy stopped the run".to_string()))
}
}
engine.set_interceptor(AbortAfterResult);
engine.set_interceptor(AbortAfterResult {
lifecycle: observed.clone(),
});
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
@@ -1210,11 +1320,11 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
assert_eq!(tool.call_count(), 1);
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
["committed", "published", "post_tool_call", "run-returned"]
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
agen::EngineError::Aborted(ref reason)
)) if reason == "policy stopped the run"
));
@@ -1239,3 +1349,93 @@ async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
} if call_id == "call_confirmed"
)));
}
#[derive(Clone, Copy)]
enum PostToolStopMode {
Abort,
Failure,
}
struct StopFirstParallelResult(PostToolStopMode);
#[async_trait]
impl Interceptor for StopFirstParallelResult {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, ()>,
) -> InterceptorResult<PostToolAction> {
if info.call.id != "call_fast" {
return Ok(PostToolAction::Continue);
}
tokio::time::sleep(Duration::from_millis(5)).await;
match self.0 {
PostToolStopMode::Abort => Ok(PostToolAction::Abort("stop parallel batch".to_string())),
PostToolStopMode::Failure => Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
"reject parallel batch",
)),
}
}
}
#[tokio::test]
async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning() {
for mode in [PostToolStopMode::Abort, PostToolStopMode::Failure] {
let first_response = vec![
Event::tool_use_start(0, "call_fast", "fast"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_ready", "ready"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let second_response = vec![
Event::text_block_start(0),
Event::text_delta(0, "next run completed"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![first_response, second_response]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("fast", 0).definition());
engine.register_tool(SlowTool::new("ready", 1).definition());
engine.set_interceptor(StopFirstParallelResult(mode));
let mut history = History::new();
let output = engine.run(&mut history, "parallel stop").await;
match mode {
PostToolStopMode::Abort => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Aborted(ref reason)
)) if reason == "stop parallel batch"
)),
PostToolStopMode::Failure => assert!(matches!(
output.result,
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
EngineError::Interceptor(ref failure)
)) if failure.phase() == InterceptorPhase::PostToolCall
)),
}
let terminal_ids: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(terminal_ids.len(), 2);
assert!(terminal_ids.contains(&"call_fast"));
assert!(terminal_ids.contains(&"call_ready"));
let mut engine = output.engine;
let next = engine.run(&mut history, "next run").await;
assert!(matches!(next, EngineRunExit::Finished));
}
}
+114 -49
View File
@@ -1,8 +1,11 @@
use crate::BackendOrigin;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use std::fmt;
use std::time::Duration;
use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendAuthTarget {
pub base_url: String,
@@ -28,23 +31,6 @@ impl BackendAuthTarget {
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DeviceLoginStartResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DeviceLoginPollResponse {
pub status: String,
pub access_token: Option<String>,
pub token_type: Option<String>,
}
#[derive(Debug)]
pub enum BackendAuthClientError {
Http(reqwest::Error),
@@ -74,16 +60,6 @@ impl From<reqwest::Error> for BackendAuthClientError {
}
}
#[derive(Debug, Serialize)]
struct DeviceLoginStartRequest<'a> {
client_name: Option<&'a str>,
}
#[derive(Debug, Serialize)]
struct DeviceLoginPollRequest<'a> {
device_code: &'a str,
}
pub async fn start_device_login(
target: &BackendAuthTarget,
client_name: Option<&str>,
@@ -91,7 +67,9 @@ pub async fn start_device_login(
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/start"))
.json(&DeviceLoginStartRequest { client_name })
.json(&DeviceLoginStartRequest {
client_name: client_name.map(ToOwned::to_owned),
})
.send()
.await?;
parse_json_response(response).await
@@ -104,12 +82,38 @@ pub async fn poll_device_login(
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/poll"))
.json(&DeviceLoginPollRequest { device_code })
.json(&DeviceLoginPollRequest {
device_code: device_code.to_string(),
})
.send()
.await?;
parse_json_response(response).await
}
fn device_login_poll_result(
response: DeviceLoginPollResponse,
) -> Result<Option<String>, BackendAuthClientError> {
match response.status {
DeviceLoginPollStatus::Approved => response
.access_token
.ok_or(BackendAuthClientError::MissingAccessToken)
.map(Some),
DeviceLoginPollStatus::Expired => Err(BackendAuthClientError::BackendStatus {
status: 410,
body: "device login expired".to_string(),
}),
DeviceLoginPollStatus::Denied => Err(BackendAuthClientError::BackendStatus {
status: 403,
body: "device login was denied".to_string(),
}),
DeviceLoginPollStatus::Consumed => Err(BackendAuthClientError::BackendStatus {
status: 409,
body: "device login was already consumed".to_string(),
}),
DeviceLoginPollStatus::Pending => Ok(None),
}
}
pub async fn wait_for_device_login(
target: &BackendAuthTarget,
device_code: &str,
@@ -119,25 +123,8 @@ pub async fn wait_for_device_login(
let started = std::time::Instant::now();
loop {
let response = poll_device_login(target, device_code).await?;
match response.status.as_str() {
"approved" => {
return response
.access_token
.ok_or(BackendAuthClientError::MissingAccessToken);
}
"expired" => {
return Err(BackendAuthClientError::BackendStatus {
status: 410,
body: "device login expired".to_string(),
});
}
"consumed" => {
return Err(BackendAuthClientError::BackendStatus {
status: 409,
body: "device login was already consumed".to_string(),
});
}
_ => {}
if let Some(access_token) = device_login_poll_result(response)? {
return Ok(access_token);
}
if started.elapsed() >= expires_in {
return Err(BackendAuthClientError::BackendStatus {
@@ -162,3 +149,81 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
}
Ok(response.json::<T>().await?)
}
#[cfg(test)]
mod tests {
use super::*;
use workspace_api::DeviceAccessTokenType;
fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse {
DeviceLoginPollResponse {
status,
access_token: None,
token_type: None,
}
}
#[test]
fn device_login_start_response_enforces_shared_expiry_bounds() {
let valid = serde_json::json!({
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://yoi.example/login/device",
"verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH",
"expires_in": 600,
"interval": 5
});
assert!(serde_json::from_value::<DeviceLoginStartResponse>(valid.clone()).is_ok());
let mut expired = valid;
expired["expires_in"] = serde_json::json!(0);
assert!(serde_json::from_value::<DeviceLoginStartResponse>(expired).is_err());
}
#[test]
fn device_login_poll_response_rejects_unknown_status() {
assert!(
serde_json::from_value::<DeviceLoginPollResponse>(
serde_json::json!({"status": "future_status"}),
)
.is_err()
);
}
#[test]
fn device_login_poll_result_handles_pending_and_terminal_states() {
assert!(
device_login_poll_result(poll_response(DeviceLoginPollStatus::Pending))
.unwrap()
.is_none()
);
let approved = DeviceLoginPollResponse {
status: DeviceLoginPollStatus::Approved,
access_token: Some("access-secret".to_string()),
token_type: Some(DeviceAccessTokenType::Bearer),
};
assert_eq!(
device_login_poll_result(approved).unwrap(),
Some("access-secret".to_string())
);
assert!(matches!(
device_login_poll_result(poll_response(DeviceLoginPollStatus::Approved)),
Err(BackendAuthClientError::MissingAccessToken)
));
for (status, expected_http_status) in [
(DeviceLoginPollStatus::Expired, 410),
(DeviceLoginPollStatus::Denied, 403),
(DeviceLoginPollStatus::Consumed, 409),
] {
assert!(matches!(
device_login_poll_result(poll_response(status)),
Err(BackendAuthClientError::BackendStatus {
status,
..
}) if status == expected_http_status
));
}
}
}
+111 -46
View File
@@ -1,6 +1,6 @@
use reqwest::Method;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use ticket::{
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
@@ -9,39 +9,17 @@ use ticket::{
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
};
use workspace_api::{
ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest,
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
#[derive(Debug, Deserialize)]
struct BackendWorkerLaunchOptions {
runtimes: Vec<BackendWorkerLaunchRuntime>,
}
#[derive(Debug, Deserialize)]
struct BackendWorkerLaunchRuntime {
runtime_id: String,
worker_creation_available: bool,
working_directory_required: bool,
}
#[derive(Debug, Deserialize)]
struct BackendCreateWorkerResponse {
runtime_id: String,
worker_id: String,
}
#[derive(Debug, Deserialize)]
struct BackendWorkspaceOrchestratorResponse {
disposition: String,
worker: Option<BackendCreateWorkerResponse>,
}
/// Workspace-scoped Backend client for Ticket and Objective product state.
///
/// Construction requires both the selected Backend URL and Workspace identity.
@@ -263,11 +241,22 @@ impl BackendWorkspaceProductClient {
)
}
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory")
}
pub fn list_memory_staging(
&self,
limit: usize,
) -> Result<MemoryStagingListResponse, BackendWorkspaceClientError> {
self.get_json(&format!("/memory/staging?limit={limit}"))
}
pub fn launch_ticket_intake(
&self,
ticket_id: &str,
) -> Result<String, BackendWorkspaceClientError> {
let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?;
let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?;
let runtime = options
.runtimes
.iter()
@@ -278,19 +267,19 @@ impl BackendWorkspaceProductClient {
.to_string(),
)
})?;
let response: BackendCreateWorkerResponse = self.send_json(
Method::POST,
"/workers",
Some(&serde_json::json!({
"runtime_id": runtime.runtime_id,
"display_name": format!("intake-{ticket_id}"),
"profile": "builtin:intake",
"initial_submit": [{
"kind": "text",
"content": format!("Please handle intake for Ticket {ticket_id}.")
}]
})),
)?;
let request = CreateWorkspaceWorkerRequest {
runtime_id: runtime.runtime_id.clone(),
display_name: format!("intake-{ticket_id}"),
profile: Some("builtin:intake".to_string()),
ticket_assignment: None,
initial_submit: vec![protocol::Segment::Text {
content: format!("Please handle intake for Ticket {ticket_id}."),
}],
working_directory: None,
control_operation_id: None,
};
let response: BrowserCreateWorkerResponse =
self.send_json(Method::POST, "/workers", Some(&request))?;
Ok(format!(
"Started Intake Worker {}/{} for Ticket {ticket_id}",
response.runtime_id, response.worker_id
@@ -298,7 +287,7 @@ impl BackendWorkspaceProductClient {
}
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
let response: BackendWorkspaceOrchestratorResponse =
let response: BrowserWorkspaceOrchestratorResponse =
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
let worker = response.worker.ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget(
@@ -690,6 +679,82 @@ mod tests {
(format!("http://{address}"), receiver, handle)
}
#[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"}"##;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.memory_document().unwrap();
assert_eq!(response.record_source, "workspace-sqlite");
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory ")
);
handle.join().unwrap();
}
#[test]
fn memory_staging_uses_shared_dto_with_typed_origin() {
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"imported_at_desc_candidate_id_asc","record_authority":"sqlite_workspace_authority.memory_staging","items":[{"id":"candidate-1","byte_len":128,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"Keep typed provenance.","why_useful":"Prevents trust loss.","staleness":null,"evidence":[],"source_refs":[{"session_id":"session-1","segment_id":"segment-1","entry_range":[1,2],"evidence_id":"evidence-1","origin":{"kind":"worker_input","workspace_id":"workspace-a","runtime_id":"runtime-1","worker_id":"worker-1"},"evidence_kind":"worker_session_entry","label":null,"summary":null}]}}],"diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.list_memory_staging(10).unwrap();
assert_eq!(
response.items[0].record.source_refs[0]
.origin
.as_ref()
.unwrap()
.kind,
workspace_api::MemoryEvidenceOriginKind::WorkerInput
);
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
);
handle.join().unwrap();
}
#[test]
fn memory_staging_rejects_unknown_origin_kind() {
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"order","record_authority":"authority","items":[{"id":"candidate-1","byte_len":1,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"claim","why_useful":"useful","staleness":null,"evidence":[],"source_refs":[{"session_id":null,"segment_id":null,"entry_range":null,"evidence_id":null,"origin":{"kind":"future_origin"},"evidence_kind":null,"label":null,"summary":null}]}}],"diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let error = client.list_memory_staging(10).unwrap_err();
assert!(matches!(error, BackendWorkspaceClientError::Http(_)));
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
);
handle.join().unwrap();
}
#[test]
fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
@@ -792,11 +857,11 @@ mod tests {
let (base_url, requests, handle) = response_sequence_server(vec![
(
"200 OK",
r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
),
(
"200 OK",
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
r#"{"workspace_id":"workspace-a","runtime_id":"embedded","worker_id":"worker-1","console_href":"/w/workspace-a/workers/worker-1","worker":{"runtime_id":"embedded","worker_id":"worker-1","host_id":"embedded","display_name":"Intake","label":"worker-1","profile":"builtin:intake","singleton_key":null,"tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":false,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#,
),
]);
let client = BackendWorkspaceProductClient::new_with_access_token(
@@ -824,7 +889,7 @@ mod tests {
#[test]
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
let body = r#"{"workspace_id":"workspace-a","online":true,"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2","host_id":"embedded","display_name":"Orchestrator","label":"worker-2","profile":"builtin:orchestrator","singleton_key":"workspace-orchestrator","tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":true,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
+167 -78
View File
@@ -18,10 +18,11 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
WorkerManifest, WorkerMeta,
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
};
/// Partial-form Worker manifest. Every field is optional; one or more
@@ -67,9 +68,6 @@ pub struct WorkerManifestConfig {
/// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)]
pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// External Agent Skills directories. See [`crate::SkillsConfig`].
#[serde(default)]
pub skills: Option<SkillsConfig>,
@@ -193,18 +191,86 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub staging: Option<bool>,
pub staging_tools: Option<bool>,
#[serde(default)]
pub resident: Option<MemoryResidentProfileConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
#[serde(default)]
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryResidentProfileConfigPartial {
#[serde(default)]
pub inject_summary: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryExtractionProfileConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub model: Option<ModelManifest>,
#[serde(default)]
pub threshold: Option<u64>,
#[serde(default)]
pub worker_max_turns: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfigPartial {
#[serde(default)]
pub request_enabled: Option<bool>,
}
impl MemoryFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
staging: other.staging.or(self.staging),
staging_tools: other.staging_tools.or(self.staging_tools),
resident: merge_option(
self.resident,
other.resident,
MemoryResidentProfileConfigPartial::merge,
),
extraction: merge_option(
self.extraction,
other.extraction,
MemoryExtractionProfileConfigPartial::merge,
),
consolidation: merge_option(
self.consolidation,
other.consolidation,
MemoryConsolidationProfileConfigPartial::merge,
),
}
}
}
impl MemoryResidentProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
inject_summary: other.inject_summary.or(self.inject_summary),
}
}
}
impl MemoryExtractionProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
model: other.model.or(self.model),
threshold: other.threshold.or(self.threshold),
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
}
}
}
@@ -253,13 +319,21 @@ impl MergeRequestFeatureConfigPartial {
}
}
impl MemoryConsolidationProfileConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
request_enabled: other.request_enabled.or(self.request_enabled),
}
}
}
impl From<FeatureConfigPartial> for FeatureConfig {
fn from(value: FeatureConfigPartial) -> Self {
Self {
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
memory: value
.memory
.map(MemoryFeatureConfig::from)
.map(ResolvedMemoryFeatureConfig::from)
.unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
@@ -329,20 +403,52 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
}
}
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
fn from(value: MemoryFeatureConfigPartial) -> Self {
let resident = value.resident.unwrap_or_default();
let extraction = value.extraction.unwrap_or_default();
let consolidation = value.consolidation.unwrap_or_default();
Self {
enabled: value.enabled.unwrap_or_default(),
staging: value.staging.unwrap_or_default(),
profile: MemoryFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
staging_tools: value.staging_tools.unwrap_or_default(),
resident: MemoryResidentProfileConfig {
inject_summary: resident.inject_summary.unwrap_or(true),
},
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
threshold: extraction.threshold.or(Some(50_000)),
worker_max_turns: extraction
.worker_max_turns
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
},
consolidation: MemoryConsolidationProfileConfig {
request_enabled: consolidation.request_enabled.unwrap_or(true),
},
},
workspace_settings: None,
}
}
}
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: MemoryFeatureConfig) -> Self {
impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
fn from(value: ResolvedMemoryFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
staging: Some(value.staging),
enabled: Some(value.profile.enabled),
staging_tools: Some(value.profile.staging_tools),
resident: Some(MemoryResidentProfileConfigPartial {
inject_summary: Some(value.profile.resident.inject_summary),
}),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
threshold: value.profile.extraction.threshold,
worker_max_turns: value.profile.extraction.worker_max_turns,
}),
consolidation: Some(MemoryConsolidationProfileConfigPartial {
request_enabled: Some(value.profile.consolidation.request_enabled),
}),
}
}
}
@@ -543,13 +649,9 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
(removed; use compaction.prune_protected_tokens)",
));
}
if value
.get("memory")
.and_then(toml::Value::as_table)
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
{
if value.get("memory").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
"unknown field in manifest: memory (removed; configure feature.memory)",
));
}
if value
@@ -633,11 +735,6 @@ impl WorkerManifestConfig {
for rule in &mut self.delegation_scope.deny {
rule.target = join_if_relative(base, &rule.target);
}
if let Some(ref mut memory) = self.memory
&& let Some(ref mut root) = memory.workspace_root
{
*root = join_if_relative(base, root);
}
if let Some(ref mut compaction) = self.compaction
&& let Some(ref mut cp) = compaction.model
{
@@ -682,7 +779,6 @@ impl WorkerManifestConfig {
CompactionConfigPartial::merge,
),
web: merge_option(self.web, upper.web, WebConfig::merge),
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
}
}
@@ -754,32 +850,6 @@ impl crate::WebFetchConfig {
}
}
impl MemoryConfig {
fn merge(self, upper: Self) -> Self {
Self {
workspace_root: upper.workspace_root.or(self.workspace_root),
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
inject_summary: upper.inject_summary.or(self.inject_summary),
workspace_id: upper.workspace_id.or(self.workspace_id),
settings_revision: upper.settings_revision.or(self.settings_revision),
language: upper.language.or(self.language),
extract_model: upper.extract_model.or(self.extract_model),
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
extract_worker_max_turns: upper
.extract_worker_max_turns
.or(self.extract_worker_max_turns),
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
consolidation_threshold_files: upper
.consolidation_threshold_files
.or(self.consolidation_threshold_files),
consolidation_threshold_bytes: upper
.consolidation_threshold_bytes
.or(self.consolidation_threshold_bytes),
}
}
}
impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
@@ -1223,7 +1293,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
mcp: cfg.mcp,
compaction,
web: cfg.web,
memory: cfg.memory,
skills: cfg.skills,
profile: None,
})
@@ -1271,7 +1340,6 @@ mod tests {
session: None,
compaction: None,
web: None,
memory: None,
skills: None,
}
}
@@ -1846,29 +1914,50 @@ prune_protected_turns = 3
}
#[test]
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
let bad = r#"
[memory]
extract_worker_max_input_tokens = 30000
"#;
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
assert!(
err.to_string()
.contains("memory.extract_worker_max_input_tokens"),
"unexpected error: {err}"
);
fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
let cfg = WorkerManifestConfig::from_toml(
r#"
[feature.memory]
enabled = true
staging_tools = false
[feature.memory.resident]
inject_summary = false
[feature.memory.extraction]
enabled = true
threshold = 42000
worker_max_turns = 2
[feature.memory.consolidation]
request_enabled = false
"#,
)
.unwrap();
let memory = cfg.feature.memory.unwrap();
assert_eq!(memory.enabled, Some(true));
assert_eq!(memory.staging_tools, Some(false));
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
let extraction = memory.extraction.unwrap();
assert_eq!(extraction.enabled, Some(true));
assert_eq!(extraction.threshold, Some(42_000));
assert_eq!(extraction.worker_max_turns, Some(2));
}
#[test]
fn from_toml_accepts_extract_worker_max_turns() {
let cfg = WorkerManifestConfig::from_toml(
fn from_toml_rejects_legacy_top_level_memory_authority() {
let err = WorkerManifestConfig::from_toml(
r#"
[memory]
extract_worker_max_turns = 2
"#,
)
.unwrap();
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
.unwrap_err();
assert!(
err.to_string().contains("memory"),
"unexpected error: {err}"
);
}
#[test]
@@ -1948,7 +2037,7 @@ worker_max_turns = 7
fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
@@ -2025,8 +2114,8 @@ enabled = false
}
);
assert!(!manifest.feature.orchestration.enabled);
assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.memory.staging);
assert!(!manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.profile.staging_tools);
assert!(!manifest.feature.objective.enabled);
}
@@ -2074,7 +2163,7 @@ readiness_check = true
enabled = true
[feature.memory]
staging = true
staging_tools = true
[feature.manage_workdir]
enabled = true
@@ -2111,8 +2200,8 @@ enabled = true
})
.try_into()
.unwrap();
assert!(manifest.feature.memory.enabled);
assert!(manifest.feature.memory.staging);
assert!(manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.profile.staging_tools);
assert!(manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket.authoring);
+1 -1
View File
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
/// See [`crate::MemoryConfig::extract_worker_max_turns`].
/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
+506 -147
View File
@@ -47,6 +47,7 @@ use serde::{Deserialize, Serialize};
/// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerManifest {
pub worker: WorkerMeta,
pub model: ModelManifest,
@@ -80,11 +81,6 @@ pub struct WorkerManifest {
pub mcp: McpConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
/// storage, extraction, consolidation, and resident injection, but memory
/// tools are surfaced only when `[feature.memory].enabled = true`.
#[serde(default)]
pub memory: Option<MemoryConfig>,
/// First-class web tools configuration. Network access remains fail-closed
/// under this config; WebSearch/WebFetch schemas are surfaced only when
/// `[feature.web].enabled = true`.
@@ -109,12 +105,12 @@ pub struct WorkerManifest {
/// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FeatureConfig {
#[serde(default)]
pub task: FeatureFlagConfig,
#[serde(default)]
pub memory: MemoryFeatureConfig,
pub memory: ResolvedMemoryFeatureConfig,
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
@@ -147,7 +143,7 @@ impl Default for FeatureConfig {
fn default() -> Self {
Self {
task: FeatureFlagConfig::disabled(),
memory: MemoryFeatureConfig::disabled(),
memory: ResolvedMemoryFeatureConfig::default(),
web: FeatureFlagConfig::disabled(),
image: FeatureFlagConfig::disabled(),
sub_worker: FeatureFlagConfig::disabled(),
@@ -222,34 +218,139 @@ const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryFeatureConfig {
#[serde(default)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryFeatureProfileConfig {
pub enabled: bool,
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
#[serde(default)]
pub staging: bool,
pub staging_tools: bool,
pub resident: MemoryResidentProfileConfig,
pub extraction: MemoryExtractionProfileConfig,
pub consolidation: MemoryConsolidationProfileConfig,
}
impl MemoryFeatureConfig {
pub const fn disabled() -> Self {
Self {
enabled: false,
staging: false,
}
impl MemoryFeatureProfileConfig {
pub fn disabled() -> Self {
Self::default()
}
pub const fn enabled() -> Self {
pub fn enabled() -> Self {
Self {
enabled: true,
staging: false,
..Self::default()
}
}
}
impl Default for MemoryFeatureConfig {
impl Default for MemoryFeatureProfileConfig {
fn default() -> Self {
Self::disabled()
Self {
enabled: false,
staging_tools: false,
resident: MemoryResidentProfileConfig::default(),
extraction: MemoryExtractionProfileConfig::default(),
consolidation: MemoryConsolidationProfileConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryResidentProfileConfig {
pub inject_summary: bool,
}
impl Default for MemoryResidentProfileConfig {
fn default() -> Self {
Self {
inject_summary: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryExtractionProfileConfig {
pub enabled: bool,
pub model: Option<ModelManifest>,
pub threshold: Option<u64>,
pub worker_max_turns: Option<u32>,
}
impl Default for MemoryExtractionProfileConfig {
fn default() -> Self {
Self {
enabled: true,
model: None,
threshold: Some(50_000),
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MemoryConsolidationProfileConfig {
pub request_enabled: bool,
}
impl Default for MemoryConsolidationProfileConfig {
fn default() -> Self {
Self {
request_enabled: true,
}
}
}
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default, deny_unknown_fields)]
pub struct ResolvedMemoryFeatureConfig {
pub profile: MemoryFeatureProfileConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
}
impl ResolvedMemoryFeatureConfig {
pub fn enabled(&self) -> bool {
self.profile.enabled
}
pub fn bind_workspace_settings(
&mut self,
settings: WorkspaceMemorySettingsSnapshot,
) -> Result<(), &'static str> {
if !self.profile.enabled {
if self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
return Ok(());
}
if self.workspace_settings.is_some() {
return Err("memory Workspace settings are already bound");
}
self.workspace_settings = Some(settings);
Ok(())
}
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
self.workspace_settings.clone()
}
pub fn validate_execution(&self) -> Result<(), &'static str> {
if self.profile.enabled && self.workspace_settings.is_none() {
return Err("enabled Memory feature requires trusted Workspace settings");
}
if !self.profile.enabled && self.workspace_settings.is_some() {
return Err("disabled Memory feature must not carry Workspace settings");
}
if let Some(settings) = &self.workspace_settings
&& (settings.settings_revision == 0
|| !is_normalized_workspace_memory_language(&settings.language))
{
return Err("Memory Workspace settings snapshot metadata is invalid");
}
Ok(())
}
}
@@ -484,98 +585,6 @@ pub struct WorkspaceMemorySettingsSnapshot {
pub language: String,
}
/// Memory subsystem configuration. Presence in the manifest enables
/// memory; `workspace_root` pins the memory workspace explicitly. When it
/// is absent, memory resolution searches upward from the Worker's pwd for a
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
/// as a memory root.
///
/// All fields are `Option`; defaults are applied at the consumer
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryConfig {
/// Override for the memory workspace root. When `None`, consumers resolve
/// the root from their default path and ancestor `.yoi/memory` markers.
/// When set, must be an absolute path.
#[serde(default)]
pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// Lines of context before and after each match in query excerpts.
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
#[serde(default)]
pub query_excerpt_lines: Option<usize>,
/// Whether the body of `memory/summary.md` is exposed in the resident
/// system-prompt section. `None` ⇒ enabled.
#[serde(default)]
pub inject_summary: Option<bool>,
/// Workspace that owns the bound Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
/// Monotonic revision of the bound Workspace Memory settings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings_revision: Option<u64>,
/// Language from the bound Workspace Memory settings revision.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// Optional model for the extract worker. When `None`,
/// the main engine model is cloned via `clone_boxed()`. Lightweight
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
/// recommended.
#[serde(default)]
pub extract_model: Option<ModelManifest>,
/// Cumulative input-token threshold (since the last extract pointer)
/// that triggers an extract run. `None` disables the extract trigger
/// entirely; memory tools and resident injection still work, only
/// the auto-extract trigger is dormant.
#[serde(default)]
pub extract_threshold: Option<u64>,
/// Optional maximum extract-worker tool-loop depth. `None` leaves
/// the worker unlimited; the default bounds runaway short-context
/// loops. Falls through to
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
#[serde(default)]
pub extract_worker_max_turns: Option<u32>,
/// Optional model for the consolidation worker. When
/// `None`, the main engine model is cloned via `clone_boxed()`.
/// Reasoning-class models are recommended.
#[serde(default)]
pub consolidation_model: Option<ModelManifest>,
/// Consolidation trigger: file-count threshold of `_staging/`. The
/// consolidation run fires when the staging directory has at least
/// this many entries. Either threshold reaching its limit fires
/// consolidation (logical OR). `None` for both thresholds ⇒
/// consolidation disabled.
#[serde(default)]
pub consolidation_threshold_files: Option<usize>,
/// Consolidation trigger: byte-size threshold across all `_staging/`
/// entries. Either threshold reaching its limit fires consolidation.
/// `None` for both thresholds ⇒ consolidation disabled.
#[serde(default)]
pub consolidation_threshold_bytes: Option<u64>,
}
impl MemoryConfig {
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
self.workspace_id = Some(snapshot.workspace_id.clone());
self.settings_revision = Some(snapshot.settings_revision);
self.language = Some(snapshot.language.clone());
}
/// Return the complete bound Workspace settings snapshot, if every field is present.
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
Some(WorkspaceMemorySettingsSnapshot {
workspace_id: self.workspace_id.clone()?,
settings_revision: self.settings_revision?,
language: self.language.clone()?,
})
}
}
/// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta {
@@ -931,6 +940,12 @@ impl Default for CompactionConfig {
}
impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some()
|| self.plugins.has_resolved_plan()
|| self.feature.memory.workspace_settings.is_some()
}
/// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?;
@@ -941,6 +956,212 @@ impl WorkerManifest {
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryFeatureConfig {
enabled: bool,
staging: bool,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct LegacyMemoryConfig {
#[serde(rename = "workspace_root")]
_workspace_root: Option<PathBuf>,
#[serde(rename = "query_result_limit")]
_query_result_limit: Option<usize>,
#[serde(rename = "query_excerpt_lines")]
_query_excerpt_lines: Option<usize>,
inject_summary: Option<bool>,
workspace_id: Option<String>,
settings_revision: Option<u64>,
language: Option<String>,
extract_model: Option<ModelManifest>,
extract_threshold: Option<u64>,
extract_worker_max_turns: Option<u32>,
consolidation_model: Option<ModelManifest>,
consolidation_threshold_files: Option<usize>,
consolidation_threshold_bytes: Option<u64>,
}
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
pub fn write_persisted_worker_manifest_snapshot(
manifest: &WorkerManifest,
) -> Result<serde_json::Value, serde_json::Error> {
Ok(serde_json::json!({
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
"manifest": serde_json::to_value(manifest)?,
}))
}
/// Read a durable resolved Worker Manifest through the versioned compatibility
/// boundary. Runtime code must not deserialize persisted snapshots directly.
pub fn read_persisted_worker_manifest_snapshot(
snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let object = snapshot.as_object().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot must be an object",
))
})?;
if let Some(version) = object.get("schema_version") {
let version = version.as_u64().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot schema_version must be an integer",
))
})?;
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
)));
}
if object.len() != 2 {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot contains unknown fields",
)));
}
let manifest = object.get("manifest").cloned().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"resolved Worker manifest snapshot is missing manifest",
))
})?;
if manifest
.as_object()
.is_some_and(|manifest| manifest.contains_key("memory"))
{
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"current resolved Worker manifest contains removed top-level memory authority",
)));
}
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
}
migrate_legacy_resolved_manifest_snapshot(snapshot)
}
fn validate_persisted_worker_manifest(
manifest: WorkerManifest,
) -> Result<WorkerManifest, serde_json::Error> {
manifest
.feature
.memory
.validate_execution()
.map_err(|message| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message,
))
})?;
Ok(manifest)
}
fn migrate_legacy_resolved_manifest_snapshot(
mut snapshot: serde_json::Value,
) -> Result<WorkerManifest, serde_json::Error> {
let root = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest snapshot must be an object",
))
})?;
let legacy_memory = root.remove("memory");
let feature = root
.entry("feature")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
.ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest feature must be an object",
))
})?;
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
feature
.remove("memory")
.unwrap_or_else(|| serde_json::json!({})),
)?;
let enabled = legacy_feature_memory.enabled;
let staging_tools = legacy_feature_memory.staging;
let legacy_memory: LegacyMemoryConfig =
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
let mut workspace_settings = match (
legacy_memory.workspace_id,
legacy_memory.settings_revision,
legacy_memory.language,
) {
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
"workspace_id": workspace_id,
"settings_revision": settings_revision,
"language": language,
})),
(None, None, None) => None,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
)));
}
};
if !enabled {
workspace_settings = None;
}
let extraction_enabled = legacy_memory.extract_threshold.is_some();
if legacy_memory.consolidation_model.is_some() {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
)));
}
let consolidation_enabled = match (
legacy_memory.consolidation_threshold_files,
legacy_memory.consolidation_threshold_bytes,
) {
(None, None) => false,
(Some(5), Some(50_000)) => true,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
)));
}
};
let mut resolved = serde_json::json!({
"profile": {
"enabled": enabled,
"staging_tools": staging_tools,
"resident": {
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
},
"extraction": {
"enabled": extraction_enabled,
"model": serde_json::to_value(legacy_memory.extract_model)?,
"threshold": legacy_memory.extract_threshold,
"worker_max_turns": legacy_memory.extract_worker_max_turns,
},
"consolidation": {
"request_enabled": consolidation_enabled,
},
},
});
if let Some(workspace_settings) = workspace_settings {
resolved
.as_object_mut()
.expect("resolved Memory config is an object")
.insert("workspace_settings".to_string(), workspace_settings);
}
feature.insert("memory".to_string(), resolved);
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1246,36 +1467,182 @@ model_id = "claude-sonnet-4-20250514"
}
#[test]
fn omitted_memory_is_none() {
fn omitted_memory_feature_is_disabled() {
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.memory.is_none());
assert!(!manifest.feature.memory.profile.enabled);
assert!(manifest.feature.memory.workspace_settings.is_none());
}
#[test]
fn empty_memory_section_enables_with_default_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
let toml = format!(
"{MINIMAL_REQUIRED}\n\
[feature.memory.profile]\n\
enabled = true\n\
staging_tools = false\n\n\
[feature.memory.profile.resident]\n\
inject_summary = false\n\n\
[feature.memory.profile.extraction]\n\
enabled = true\n\
threshold = 42000\n\
worker_max_turns = 2\n\n\
[feature.memory.workspace_settings]\n\
workspace_id = \"workspace-1\"\n\
settings_revision = 7\n\
language = \"日本語\"\n"
);
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed");
assert!(mem.workspace_root.is_none());
assert_eq!(mem.inject_summary, None);
}
#[test]
fn memory_section_with_inject_summary_false() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.inject_summary, Some(false));
}
#[test]
fn memory_section_with_explicit_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert!(manifest.feature.memory.profile.enabled);
assert!(!manifest.feature.memory.profile.resident.inject_summary);
assert_eq!(
mem.workspace_root.unwrap(),
std::path::PathBuf::from("/some/where")
manifest.feature.memory.profile.extraction.threshold,
Some(42_000)
);
assert_eq!(
manifest
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"日本語"
);
}
#[test]
fn resolved_memory_execution_validation_fails_closed() {
let snapshot = WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 1,
language: "English".to_string(),
};
let mut enabled = ResolvedMemoryFeatureConfig::default();
enabled.profile.enabled = true;
assert!(enabled.validate_execution().is_err());
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
assert!(enabled.validate_execution().is_ok());
let mut disabled = ResolvedMemoryFeatureConfig::default();
disabled.workspace_settings = Some(snapshot.clone());
assert!(disabled.validate_execution().is_err());
assert!(disabled.bind_workspace_settings(snapshot).is_err());
}
#[test]
fn current_manifest_rejects_legacy_top_level_memory_authority() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
let mut manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
manifest["feature"]["memory"] = serde_json::json!({
"enabled": true,
"staging": true,
});
manifest["memory"] = serde_json::json!({
"workspace_root": "/discarded",
"query_result_limit": 999,
"inject_summary": false,
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
"extract_threshold": 1234,
"extract_worker_max_turns": 3,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000,
});
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
assert!(migrated.feature.memory.profile.enabled);
assert!(migrated.feature.memory.profile.staging_tools);
assert!(!migrated.feature.memory.profile.resident.inject_summary);
assert_eq!(
migrated.feature.memory.profile.extraction.threshold,
Some(1234)
);
assert!(
migrated
.feature
.memory
.profile
.consolidation
.request_enabled
);
assert_eq!(
migrated
.feature
.memory
.workspace_settings()
.unwrap()
.language,
"Français"
);
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
assert_eq!(current["schema_version"], 2);
assert!(current["manifest"].get("memory").is_none());
let mut disabled =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
disabled["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 9,
"language": "Français",
});
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
assert!(!disabled.feature.memory.profile.enabled);
assert!(disabled.feature.memory.workspace_settings.is_none());
}
#[test]
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
let manifest =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
let mut mixed = manifest.clone();
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
mixed["memory"] = serde_json::json!({});
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
let mut custom_policy =
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
custom_policy["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 1,
"language": "English",
"consolidation_threshold_files": 99,
"consolidation_threshold_bytes": 50000,
});
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut current = write_persisted_worker_manifest_snapshot(&current).unwrap();
current["manifest"]["memory"] = serde_json::json!({
"workspace_id": "workspace-1",
"settings_revision": 1,
"language": "English",
});
assert!(read_persisted_worker_manifest_snapshot(current).is_err());
let mut missing_settings = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
missing_settings.feature.memory.profile.enabled = true;
let missing_settings = write_persisted_worker_manifest_snapshot(&missing_settings).unwrap();
assert!(read_persisted_worker_manifest_snapshot(missing_settings).is_err());
let mut malformed_legacy = manifest.clone();
malformed_legacy["feature"]["memory"] = serde_json::json!({ "enabled": "yes" });
malformed_legacy["memory"] = serde_json::json!({ "unknown": true });
assert!(read_persisted_worker_manifest_snapshot(malformed_legacy).is_err());
assert!(
read_persisted_worker_manifest_snapshot(serde_json::json!({
"schema_version": 3,
"manifest": manifest,
}))
.is_err()
);
}
@@ -1291,14 +1658,6 @@ model_id = "claude-sonnet-4-20250514"
));
}
#[test]
fn memory_section_with_language() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap();
assert_eq!(mem.language.as_deref(), Some("Japanese"));
}
#[test]
fn reject_unknown_scheme() {
let toml =
+9 -61
View File
@@ -20,9 +20,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, paths,
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
paths,
};
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
@@ -185,7 +185,7 @@ pub fn validate_profile_execution_target(
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
if feature.memory.enabled || feature.memory.staging {
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.merge_request.show
@@ -642,7 +642,6 @@ fn resolve_profile_value(
mcp: profile.mcp,
compaction,
web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills,
};
let config =
@@ -663,51 +662,6 @@ fn resolve_profile_value(
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileMemoryConfig {
#[serde(default)]
workspace_root: Option<PathBuf>,
#[serde(default)]
query_result_limit: Option<usize>,
#[serde(default)]
query_excerpt_lines: Option<usize>,
#[serde(default)]
inject_summary: Option<bool>,
#[serde(default)]
extract_model: Option<ModelManifest>,
#[serde(default)]
extract_threshold: Option<u64>,
#[serde(default)]
extract_worker_max_turns: Option<u32>,
#[serde(default)]
consolidation_model: Option<ModelManifest>,
#[serde(default)]
consolidation_threshold_files: Option<usize>,
#[serde(default)]
consolidation_threshold_bytes: Option<u64>,
}
impl From<ProfileMemoryConfig> for MemoryConfig {
fn from(profile: ProfileMemoryConfig) -> Self {
Self {
workspace_root: profile.workspace_root,
query_result_limit: profile.query_result_limit,
query_excerpt_lines: profile.query_excerpt_lines,
inject_summary: profile.inject_summary,
workspace_id: None,
settings_revision: None,
language: None,
extract_model: profile.extract_model,
extract_threshold: profile.extract_threshold,
extract_worker_max_turns: profile.extract_worker_max_turns,
consolidation_model: profile.consolidation_model,
consolidation_threshold_files: profile.consolidation_threshold_files,
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileConfig {
@@ -738,8 +692,6 @@ struct ProfileConfig {
#[serde(default)]
web: Option<WebConfig>,
#[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>,
}
@@ -940,12 +892,6 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
}
if let Some(memory) = &profile.memory
&& let Some(root) = &memory.workspace_root
&& root.is_absolute()
{
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
}
if let Some(skills) = &profile.skills {
for dir in &skills.directories {
if dir.is_absolute() {
@@ -1299,7 +1245,9 @@ mod tests {
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")),
] {
let artifact = serde_json::json!({ "memory": { (field): value } });
let artifact = serde_json::json!({
"feature": { "memory": { (field): value } }
});
let error = resolve_profile_artifact_value(
artifact,
ProfileSource::Registry {
@@ -1351,7 +1299,7 @@ mod tests {
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
}));
assert!(!resolved.manifest.feature.memory.enabled);
assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(!resolved.manifest.feature.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled);
@@ -1630,7 +1578,7 @@ enabled = false
.unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled);
assert!(!resolved.manifest.feature.memory.profile.enabled);
assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled);
+13 -5
View File
@@ -152,13 +152,10 @@ pub enum MemoryStagingAffectedMemoryOperation {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryConsolidateStagingOperation {
#[serde(default)]
pub force: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_files: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -450,10 +447,21 @@ mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path());
let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef {
segment_id: "segment-1".into(),
range: [0, 1],
+1 -2
View File
@@ -21,8 +21,7 @@ pub struct StagingEntry {
pub id: Uuid,
pub path: PathBuf,
pub record: StagingRecord,
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`)
/// に使う。
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
pub bytes: u64,
}
+2
View File
@@ -74,6 +74,7 @@ impl ExtractedPayload {
/// Bounded evidence snippet copied into a flat staging record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StagingEvidence {
pub id: String,
pub kind: EvidenceKind,
@@ -89,6 +90,7 @@ pub struct StagingEvidence {
/// One flat staging record. One record is one consolidation decision unit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StagingRecord {
pub schema_version: u32,
pub id: String,
+3
View File
@@ -22,6 +22,7 @@ impl<'de> Deserialize<'de> for SourceRef {
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSourceRef {
#[serde(default)]
segment_id: Option<String>,
@@ -83,6 +84,7 @@ pub enum EvidenceOriginKind {
/// Bounded origin snapshot attached to extraction evidence. This is audit
/// metadata only and cannot authorize Workspace operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvidenceOrigin {
pub kind: EvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -107,6 +109,7 @@ pub struct EvidenceOrigin {
/// ranges, and short labels/summaries. It must not carry raw message bodies or
/// full tool result content.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SourceEvidenceRef {
/// Stable session id when the anchor crosses or disambiguates segments.
#[serde(default, skip_serializing_if = "Option::is_none")]
+8 -33
View File
@@ -70,24 +70,12 @@ impl WorkspaceLayout {
Self { root: root.into() }
}
/// Resolve a layout from a `MemoryConfig`.
/// Resolve a layout from the nearest Memory marker.
///
/// An explicit `memory.workspace_root` is honored exactly. Without an
/// explicit root, resolution searches `default_root` and its ancestors for
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
/// contain `.yoi` project records such as tickets from
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
/// existing call sites require a concrete layout. That fallback is a
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
/// must not be used as evidence that `.yoi` alone enables repo-local
/// memory.
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
if let Some(root) = &cfg.workspace_root {
return Self::new(root.clone());
}
/// Resolution searches `default_root` and its ancestors for the nearest
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// policy directly; resolved Worker Manifests do not carry storage paths.
pub fn resolve(default_root: &Path) -> Self {
let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root)
@@ -335,16 +323,6 @@ mod tests {
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn resolve_uses_workspace_root_when_set() {
let cfg = manifest::MemoryConfig {
workspace_root: Some(PathBuf::from("/explicit")),
..Default::default()
};
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
assert_eq!(layout.root(), Path::new("/explicit"));
}
#[test]
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap();
@@ -353,8 +331,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
@@ -366,8 +343,7 @@ mod tests {
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), workspace.as_path());
}
@@ -381,8 +357,7 @@ mod tests {
assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), child.as_path());
}
}
+45 -239
View File
@@ -9,7 +9,6 @@ use thiserror::Error;
use uuid::Uuid;
const SCHEMA_VERSION: i64 = 12;
const PREVIOUS_SCHEMA_VERSION: i64 = 11;
const MAX_BODY_BYTES: usize = 16 * 1024;
const DOMAIN_TABLES: [&str; 5] = [
"merge_requests",
@@ -37,7 +36,7 @@ impl MergeRequestState {
fn parse(v: &str) -> Result<Self, MergeRequestError> {
match v {
"draft" | "open" => Ok(Self::Open),
"open" => Ok(Self::Open),
"merged" => Ok(Self::Merged),
"closed" => Ok(Self::Closed),
_ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))),
@@ -274,6 +273,12 @@ pub struct RegisterReviewerChildSession {
pub reviewer_profile: String,
pub now: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewSubmissionAuthorization {
pub workspace_id: String,
pub subject_ref: String,
}
#[derive(Debug, Clone)]
pub struct SubmitMergeRequestReview {
pub ticket_id: String,
@@ -535,6 +540,34 @@ impl MergeRequestStore {
t.commit()?;
Ok(RequestedMergeRequestReview { request_event: e })
}
pub fn authorize_review_submission(
&self,
ticket_id: &str,
capability_token: &str,
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
let connection = self.lock()?;
connection
.query_row(
"SELECT g.workspace_id,g.subject_ref
FROM merge_request_review_grants g
JOIN merge_request_ticket_relations rel
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
JOIN merge_requests mr
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
WHERE g.capability_token=?1 AND rel.ticket_id=?2
AND g.status='issued' AND mr.state='open'",
params![capability_token, ticket_id],
|row| {
Ok(ReviewSubmissionAuthorization {
workspace_id: row.get(0)?,
subject_ref: row.get(1)?,
})
},
)
.optional()?
.ok_or_else(|| MergeRequestError::Unauthorized("review grant invalid".into()))
}
pub fn submit_review(
&self,
i: SubmitMergeRequestReview,
@@ -1321,14 +1354,9 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
match schema_state(c)? {
SchemaState::Fresh => fresh(c),
SchemaState::Current(SCHEMA_VERSION) => verify(c),
SchemaState::Current(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Current),
SchemaState::Legacy(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Legacy),
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
"unsupported schema {v}"
))),
SchemaState::Legacy(v) => Err(MergeRequestError::Operation(format!(
"unsupported legacy schema {v}"
))),
}
}
@@ -1336,26 +1364,14 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
enum SchemaState {
Fresh,
Current(i64),
Legacy(i64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreviousSchemaMarker {
Current,
Legacy,
}
fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
let (current, legacy): (bool, bool) = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema'),EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
let current: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| r.get(0),
)?;
if current && legacy {
return Err(MergeRequestError::Corrupt(
"both current and legacy schema markers exist".into(),
));
}
if current {
let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row(
"SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema",
@@ -1372,22 +1388,6 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
})?;
return Ok(SchemaState::Current(version));
}
if legacy {
let (count, version): (i64, Option<i64>) = c.query_row(
"SELECT COUNT(*),MAX(version) FROM merge_request_schema_migrations",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
if count != 1 {
return Err(MergeRequestError::Corrupt(
"legacy schema marker must contain exactly one version".into(),
));
}
let version = version.ok_or_else(|| {
MergeRequestError::Corrupt("legacy schema marker version is null".into())
})?;
return Ok(SchemaState::Legacy(version));
}
let domain_tables: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')",
[],
@@ -1402,214 +1402,20 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
}
fn fresh(c: &Connection) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?;
tables(&t, true)?;
t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?;
tables(&t)?;
t.execute(
"INSERT INTO merge_request_schema VALUES(1,?1)",
params![SCHEMA_VERSION],
)?;
fk(&t)?;
t.commit()?;
Ok(())
}
fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> {
if marker {
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?
}
fn tables(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?;
Ok(())
}
fn from_v11(
c: &Connection,
previous_marker: PreviousSchemaMarker,
) -> Result<(), MergeRequestError> {
let t = c.unchecked_transaction()?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
t.execute(
"INSERT INTO merge_request_schema VALUES(1,?1)",
params![PREVIOUS_SCHEMA_VERSION],
)?;
}
t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?;
tables(&t, false)?;
t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?;
t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?;
migrate_events(&t)?;
if previous_marker == PreviousSchemaMarker::Legacy {
t.execute("DROP TABLE merge_request_schema_migrations", [])?;
}
t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?;
fk(&t)?;
t.commit()?;
Ok(())
}
fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
let attempts = {
let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?;
s.query_map([], |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)?,
r.get::<_, String>(6)?,
r.get::<_, String>(7)?,
r.get::<_, Option<String>>(8)?,
r.get::<_, String>(9)?,
))
})?
.collect::<Result<Vec<_>, _>>()?
};
for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts {
let req = ReviewRequestedEvent {
event_id: format!("migrated-request-{a}"),
sequence: next_seq(t, &ws, &mr)?,
subject_ref: subject.clone(),
requested_by: WorkerIdentity {
runtime_id: pr.clone(),
worker_id: pw,
},
reviewer: WorkerIdentity {
runtime_id: pr,
worker_id: child,
},
created_at: time(&created)?,
};
insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?;
if status == "submitted" {
let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
let findings = {
let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?;
s.query_map(params![ws, a], |r| {
Ok(ReviewFinding {
severity: match r.get::<_, String>(0)?.as_str() {
"blocker" => FindingSeverity::Blocker,
"major" => FindingSeverity::Major,
"minor" => FindingSeverity::Minor,
_ => FindingSeverity::Note,
},
code: r.get(1)?,
path: r.get(2)?,
line: r.get(3)?,
body: r.get(4)?,
})
})?
.collect::<Result<Vec<_>, _>>()?
};
let rev = ReviewEvent {
event_id: format!("migrated-review-{a}"),
sequence: next_seq(t, &ws, &mr)?,
request_event_id: req.event_id,
subject_ref: subject,
decision: if row_dec == "approve" {
ReviewDecision::Approve
} else {
ReviewDecision::RequestChanges
},
body: row_body,
findings,
reviewer: req.reviewer,
created_at: time(&row_at)?,
};
insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)?
} else {
let at = consumed.as_deref().unwrap_or(&created);
let e = ReviewCancelledEvent {
event_id: format!("migrated-cancel-{a}"),
sequence: next_seq(t, &ws, &mr)?,
request_event_id: req.event_id,
subject_ref: subject,
reason: format!(
"legacy `{status}` review request cancelled because its capability cannot be migrated"
),
created_at: time(at)?,
};
insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)?
}
}
let completed = {
let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?;
q.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<String>>(5)?,
r.get::<_, Option<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)?,
))
})?
.collect::<Result<Vec<_>, _>>()?
};
for (
ws,
op,
_ticket,
target,
source,
result,
strategy,
resolution,
runtime,
worker,
updated,
mr,
) in completed
{
let subject = source.ok_or_else(|| {
MergeRequestError::Operation(format!("completed operation {op} lacks source evidence"))
})?;
let approval:Option<String>=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?;
let approval = approval.ok_or_else(|| {
MergeRequestError::Operation(format!(
"completed operation {op} lacks approval evidence"
))
})?;
let e = MergeEvent {
event_id: format!("migrated-merge-{op}"),
sequence: next_seq(t, &ws, &mr)?,
operation_id: op,
approval_event_id: approval,
approved_source_ref: subject,
target_ref_before: target.ok_or_else(|| {
MergeRequestError::Operation("completed operation lacks target evidence".into())
})?,
target_ref_after: result.ok_or_else(|| {
MergeRequestError::Operation("completed operation lacks result evidence".into())
})?,
strategy: if strategy.as_deref() == Some("merge") {
MergeStrategy::Merge
} else {
MergeStrategy::FastForward
},
resolution: match resolution.as_deref() {
Some("clean") => ConflictResolution::Clean,
Some("conflicts_resolved") => ConflictResolution::ConflictsResolved,
_ => ConflictResolution::None,
},
merged_by: WorkerIdentity {
runtime_id: runtime.unwrap_or_else(|| "legacy".into()),
worker_id: worker.unwrap_or_else(|| "legacy".into()),
},
created_at: time(&updated)?,
};
insert_event(
t,
&ws,
&mr,
"merge",
&e,
e.created_at,
Some(&e.operation_id),
)?;
}
Ok(())
}
fn verify(c: &Connection) -> Result<(), MergeRequestError> {
for n in DOMAIN_TABLES {
let e: bool = c.query_row(
+32 -63
View File
@@ -91,6 +91,23 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
})
.unwrap()
}
#[test]
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
let (_d, store) = fixture();
open(&store);
request(&store, "published-source", "valid-token");
let invalid = store
.authorize_review_submission("T", "invalid-token")
.unwrap_err();
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
let authorized = store
.authorize_review_submission("T", "valid-token")
.unwrap();
assert_eq!(authorized.workspace_id, "W");
assert_eq!(authorized.subject_ref, "published-source");
}
#[test]
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
let (d, s) = fixture();
@@ -284,21 +301,13 @@ fn review_revocation_invalidates_readiness() {
}
#[test]
fn legacy_v11_migration_preserves_review_events_and_replaces_marker() {
fn fresh_schema_uses_version_12_and_reopens_as_current() {
let c = Connection::open_in_memory().unwrap();
c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap();
c.execute_batch(
"CREATE TABLE unrelated_parent(left_id TEXT,right_id TEXT,PRIMARY KEY(left_id,right_id));CREATE TABLE unrelated_child(left_id TEXT REFERENCES unrelated_parent(left_id));",
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
)
.unwrap();
let unrelated_mismatch = c
.query_row("PRAGMA foreign_key_check", [], |_| Ok(()))
.unwrap_err();
assert!(
unrelated_mismatch
.to_string()
.contains("foreign key mismatch")
);
merge_request::migrate(&c).unwrap();
assert_eq!(
c.query_row("SELECT version FROM merge_request_schema", [], |r| {
@@ -307,66 +316,26 @@ fn legacy_v11_migration_preserves_review_events_and_replaces_marker() {
.unwrap(),
12
);
let legacy_marker: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!legacy_marker);
let selector: Option<String> = c
.query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0))
.unwrap();
assert!(selector.is_none());
let kinds: String = c
.query_row(
"SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
kinds,
"review_requested,review,review_requested,review_cancelled"
);
let old: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!old);
merge_request::migrate(&c).unwrap();
}
#[test]
fn failed_legacy_v11_migration_rolls_back_marker_bridge() {
fn current_schema_validation_rejects_missing_tables() {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(merge_request_id TEXT);",
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
)
.unwrap();
assert!(merge_request::migrate(&c).is_err());
for table in ["merge_request_schema_migrations", "merge_requests"] {
let exists: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
[table],
|r| r.get(0),
)
.unwrap();
assert!(exists, "{table} was not rolled back");
}
let current_marker: bool = c
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
[],
|r| r.get(0),
)
merge_request::migrate(&c).unwrap();
c.execute_batch("DROP TABLE merge_request_review_grants;")
.unwrap();
assert!(!current_marker);
let error = merge_request::migrate(&c).unwrap_err();
assert!(matches!(
error,
MergeRequestError::Corrupt(message)
if message == "missing `merge_request_review_grants`"
));
}
#[test]
+19 -1
View File
@@ -557,7 +557,6 @@ pub enum SubscriptionWorkerState {
Running,
Paused,
Stopped,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1110,6 +1109,25 @@ mod tests {
);
}
#[test]
fn worker_subscription_state_has_exactly_four_lifecycle_values() {
for (state, wire) in [
(SubscriptionWorkerState::Idle, "idle"),
(SubscriptionWorkerState::Running, "running"),
(SubscriptionWorkerState::Paused, "paused"),
(SubscriptionWorkerState::Stopped, "stopped"),
] {
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::json!(wire)
);
}
assert!(
serde_json::from_value::<SubscriptionWorkerState>(serde_json::json!("cancelled"))
.is_err()
);
}
#[test]
fn client_selector_has_no_workspace_scope_field() {
let json = serde_json::to_value(EventSubscriptionSelector::WorkspaceWorkers).unwrap();
+10 -6
View File
@@ -3,7 +3,7 @@ mod common;
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
use agen::interceptor::{Interceptor, TurnEndAction};
use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
@@ -100,8 +100,11 @@ struct PausePolicy;
#[async_trait]
impl Interceptor for PausePolicy {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
TurnEndAction::Pause
async fn on_assistant_turn_end(
&self,
_context: AssistantTurnEndContext<'_>,
) -> InterceptorResult<TurnEndAction> {
Ok(TurnEndAction::Pause)
}
}
@@ -195,7 +198,7 @@ async fn run_and_persist(
)
.unwrap();
}
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::LimitReached) => {
session_store::save_run_completed(
store,
session_id,
@@ -350,7 +353,8 @@ async fn session_run_with_tool_call() {
async fn session_resume_after_pause() {
let (_dir, store) = make_store();
// First run: tool call with pause policy → Paused
// First terminal assistant response requests a tool; the assistant-turn
// interceptor pauses before the Engine enters the tool phase.
let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition());
@@ -386,7 +390,7 @@ async fn session_resume_after_pause() {
// Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2));
assert_eq!(state.active_run_turn_count, Some(1));
}
#[tokio::test]
+122
View File
@@ -0,0 +1,122 @@
-- 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, repository_id TEXT, ref_selector TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
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);
+2 -6
View File
@@ -26,11 +26,7 @@ pub mod config;
mod sqlite_schema;
pub mod tool;
pub use sqlite_schema::{
LATEST_SQLITE_TICKET_SCHEMA_VERSION, migrate_sqlite_ticket_resource_key_schema_in_transaction,
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_through,
verify_sqlite_ticket_schema,
};
pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema};
const REQUIRED_FIELDS: [&str; 4] = ["title", "state", "created_at", "updated_at"];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
@@ -2576,7 +2572,7 @@ impl SqliteTicketBackend {
}
}
/// Opens a standalone Ticket backend, applying all Ticket-owned migrations once.
/// Opens a standalone Ticket backend at the current canonical schema baseline.
pub fn open(db_path: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Result<Self> {
let backend = Self::configured(db_path, workspace_id);
let connection = backend.connect()?;
+59 -676
View File
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
#[derive(Clone, Copy)]
struct Migration {
@@ -16,38 +16,11 @@ struct Migration {
apply: fn(&Connection) -> Result<()>,
}
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "create_typed_ticket_tables",
apply: create_typed_ticket_tables,
},
Migration {
version: 2,
name: "add_ticket_repository_target",
apply: add_ticket_repository_target,
},
Migration {
version: 3,
name: "convert_legacy_reviews_to_comments",
apply: retire_legacy_ticket_review_events,
},
Migration {
version: 4,
name: "add_ticket_query_indexes",
apply: add_ticket_query_indexes,
},
Migration {
version: 5,
name: "add_workspace_human_keys",
apply: add_workspace_human_keys,
},
Migration {
version: 6,
name: "rename_workspace_resource_keys",
apply: rename_workspace_resource_keys,
},
];
const MIGRATIONS: &[Migration] = &[Migration {
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
name: "ticket schema baseline",
apply: create_latest_ticket_schema,
}];
#[derive(Clone, Copy)]
struct ExpectedColumn {
@@ -258,30 +231,12 @@ const fn column(
}
}
/// Applies the Ticket crate's SQLite migrations and verifies the resulting schema.
/// Creates and verifies the Ticket crate's latest SQLite schema.
///
/// This is a startup/standalone-open operation. Normal Ticket request handling must
/// use [`verify_sqlite_ticket_schema`] instead, so request paths never acquire DDL
/// authority.
pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
migrate_sqlite_ticket_schema_through(connection, LATEST_SQLITE_TICKET_SCHEMA_VERSION)
}
/// Applies Ticket migrations only through `target_version`.
///
/// This exists for the Workspace Server's ordered migration bridge: older Server
/// migrations must materialize the Ticket schema shape they were written against
/// before the current Ticket migration is applied at the matching Server version.
#[doc(hidden)]
pub fn migrate_sqlite_ticket_schema_through(
connection: &Connection,
target_version: i64,
) -> Result<()> {
if !(1..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&target_version) {
return Err(TicketError::Sqlite(format!(
"unsupported Ticket schema migration target {target_version}"
)));
}
connection
.busy_timeout(Duration::from_secs(5))
.map_err(sqlite_err)?;
@@ -302,25 +257,10 @@ pub fn migrate_sqlite_ticket_schema_through(
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
let applied = load_applied_migrations(connection)?;
validate_applied_migrations(&applied)?;
if let Some(version) = applied
.keys()
.copied()
.find(|version| *version > target_version)
{
return Err(TicketError::Sqlite(format!(
"Ticket schema version {version} is newer than requested migration target {target_version}"
)));
}
for migration in MIGRATIONS
.iter()
.filter(|migration| migration.version <= target_version)
{
if applied.contains_key(&migration.version) {
continue;
}
if applied.is_empty() {
let migration = MIGRATIONS
.first()
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?;
(migration.apply)(connection)?;
connection
.execute(
@@ -333,24 +273,11 @@ pub fn migrate_sqlite_ticket_schema_through(
],
)
.map_err(sqlite_err)?;
} else {
validate_applied_migrations(&applied)?;
}
if target_version == LATEST_SQLITE_TICKET_SCHEMA_VERSION {
verify_sqlite_ticket_schema(connection)
} else {
let applied = load_applied_migrations(connection)?;
let expected = MIGRATIONS
.iter()
.filter(|migration| migration.version <= target_version)
.map(|migration| (migration.version, migration.name.to_string()))
.collect::<BTreeMap<_, _>>();
if applied != expected {
return Err(TicketError::Sqlite(format!(
"Ticket schema migration history does not match target version {target_version}"
)));
}
Ok(())
}
verify_sqlite_ticket_schema(connection)
})();
match result {
@@ -362,47 +289,6 @@ pub fn migrate_sqlite_ticket_schema_through(
}
}
/// Applies the resource-key Ticket migration inside a transaction owned by the
/// Workspace Server. The caller must provide an active transaction; this function
/// deliberately does not begin or commit one so the Ticket and Server migration
/// markers can be persisted atomically.
#[doc(hidden)]
pub fn migrate_sqlite_ticket_resource_key_schema_in_transaction(
connection: &Connection,
) -> 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)?;
let applied = load_applied_migrations(connection)?;
validate_applied_migrations(&applied)?;
if applied.contains_key(&LATEST_SQLITE_TICKET_SCHEMA_VERSION) {
return verify_sqlite_ticket_schema(connection);
}
let expected_previous = LATEST_SQLITE_TICKET_SCHEMA_VERSION - 1;
if applied.len() != expected_previous as usize || !applied.contains_key(&expected_previous) {
return Err(TicketError::Sqlite(format!(
"Ticket schema must be at version {expected_previous} before the resource-key migration"
)));
}
let migration = MIGRATIONS
.last()
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".to_string()))?;
(migration.apply)(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at) VALUES (?1, ?2, datetime('now'))",
params![migration.version, migration.name],
)
.map_err(sqlite_err)?;
verify_sqlite_ticket_schema(connection)
}
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
let mut diagnostics = Vec::new();
@@ -539,238 +425,9 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
}
fn create_typed_ticket_tables(connection: &Connection) -> Result<()> {
fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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
);
"#,
)
.map_err(sqlite_err)
}
fn add_ticket_repository_target(connection: &Connection) -> Result<()> {
add_column_if_missing(connection, "typed_tickets", "repository_id", "TEXT")?;
add_column_if_missing(connection, "typed_tickets", "ref_selector", "TEXT")
}
fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> {
// Historical prose remains visible for audit, but it is explicitly converted to a
// non-authoritative comment. Approval authority now lives only in Merge Requests.
connection
.execute_batch(
r#"
INSERT OR REPLACE INTO typed_ticket_event_attributes
(workspace_id, ticket_id, event_index, key, value)
SELECT workspace_id, ticket_id, event_index, 'legacy_event_kind', 'review'
FROM typed_ticket_events WHERE kind = 'review';
UPDATE typed_ticket_events
SET kind = 'comment', status = NULL, heading = 'Legacy review (non-authoritative)'
WHERE kind = 'review';
DELETE FROM typed_ticket_event_attributes
WHERE key IN ('result', 'review_result', 'status')
AND EXISTS (
SELECT 1 FROM typed_ticket_events event
WHERE event.workspace_id = typed_ticket_event_attributes.workspace_id
AND event.ticket_id = typed_ticket_event_attributes.ticket_id
AND event.event_index = typed_ticket_event_attributes.event_index
AND event.heading = 'Legacy review (non-authoritative)'
);
"#,
)
.map_err(sqlite_err)
}
fn add_ticket_query_indexes(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX IF NOT EXISTS typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX IF NOT EXISTS typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX IF NOT EXISTS typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
"#,
)
.map_err(sqlite_err)
}
fn add_workspace_human_keys(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS workspace_resource_human_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),
human_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, human_key)
);
CREATE TABLE IF NOT EXISTS workspace_resource_human_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)
);
INSERT OR IGNORE INTO workspace_resource_human_keys (
workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at
)
SELECT workspace_id,
'ticket',
ticket_id,
ROW_NUMBER() OVER (
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
),
'T-' || ROW_NUMBER() OVER (
PARTITION BY workspace_id ORDER BY created_at ASC, ticket_id ASC
),
COALESCE(created_at, updated_at)
FROM typed_tickets;
INSERT INTO workspace_resource_human_key_counters (
workspace_id, resource_kind, next_sequence
)
SELECT workspace_id, 'ticket', MAX(sequence) + 1
FROM workspace_resource_human_keys
WHERE resource_kind = 'ticket'
GROUP BY workspace_id
ON CONFLICT(workspace_id, resource_kind) DO UPDATE SET
next_sequence = MAX(next_sequence, excluded.next_sequence);
"#,
)
.map_err(sqlite_err)
}
fn rename_workspace_resource_keys(connection: &Connection) -> Result<()> {
connection
.execute_batch(
r#"
ALTER TABLE workspace_resource_human_keys RENAME TO workspace_resource_keys;
ALTER TABLE workspace_resource_keys RENAME COLUMN human_key TO resource_key;
ALTER TABLE workspace_resource_human_key_counters RENAME TO workspace_resource_key_counters;
DROP INDEX IF EXISTS idx_workspace_resource_human_keys_reverse;
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
"#,
)
.map_err(sqlite_err)
}
fn add_column_if_missing(
connection: &Connection,
table: &str,
column: &str,
declaration: &str,
) -> Result<()> {
let columns = load_columns(connection, table)?;
if columns.iter().any(|found| found.name == column) {
return Ok(());
}
connection
.execute_batch(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {declaration}"
))
.execute_batch(include_str!("latest_schema.sql"))
.map_err(sqlite_err)
}
@@ -796,33 +453,17 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
}
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
for (&version, name) in applied {
let Some(expected) = MIGRATIONS
.iter()
.find(|migration| migration.version == version)
else {
return Err(TicketError::Sqlite(format!(
"unsupported Ticket schema migration version {version}; latest supported version is {LATEST_SQLITE_TICKET_SCHEMA_VERSION}"
)));
};
if name != expected.name {
return Err(TicketError::Sqlite(format!(
"Ticket schema migration {version} is named {name:?}, expected {:?}",
expected.name
)));
}
let expected = BTreeMap::from([(
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
MIGRATIONS[0].name.to_string(),
)]);
if applied == &expected {
Ok(())
} else {
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must contain only the canonical version {LATEST_SQLITE_TICKET_SCHEMA_VERSION} baseline marker"
)))
}
for migration in MIGRATIONS {
if applied.keys().any(|version| *version > migration.version)
&& !applied.contains_key(&migration.version)
{
return Err(TicketError::Sqlite(format!(
"Ticket schema migration history has a gap at version {}",
migration.version
)));
}
}
Ok(())
}
#[derive(Debug)]
@@ -1189,223 +830,16 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap();
let versions = load_applied_migrations(&connection).unwrap();
assert_eq!(versions.len(), 6);
assert_eq!(
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
Some(&"rename_workspace_resource_keys".to_string())
versions,
BTreeMap::from([(
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
"ticket schema baseline".to_string(),
)])
);
}
#[test]
fn adopts_existing_current_schema_without_losing_data() {
let connection = Connection::open_in_memory().unwrap();
create_typed_ticket_tables(&connection).unwrap();
add_ticket_repository_target(&connection).unwrap();
connection
.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, repository_id, ref_selector
) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'kept', 'open',
'task', 'medium', 'body', 'ready', 1, 'main', 'develop')",
[],
)
.unwrap();
connection
.execute_batch(
"INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
) VALUES (
'workspace-1', 'ticket-1', 0, 'comment', 'hare',
'2026-08-10T00:00:00Z', 'Evidence', 'event kept'
);
INSERT INTO typed_ticket_event_references (
workspace_id, ticket_id, event_index, ordinal, kind, target
) VALUES ('workspace-1', 'ticket-1', 0, 0, 'commit', 'abc123');
INSERT INTO typed_ticket_relations (
workspace_id, ticket_id, kind, target, note, author, at
) VALUES (
'workspace-1', 'ticket-1', 'related', 'ticket-2', 'relation kept',
'hare', '2026-08-10T00:00:00Z'
);
INSERT INTO typed_ticket_orchestration_plans (
workspace_id, ticket_id, record_id, kind, note, author, at
) VALUES (
'workspace-1', 'ticket-1', 'plan-1', 'waiting_capacity_note',
'plan kept', 'hare', '2026-08-10T00:00:00Z'
);
INSERT INTO typed_ticket_artifacts (
workspace_id, ticket_id, relative_path, content
) VALUES ('workspace-1', 'ticket-1', 'evidence.txt', X'6b657074');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let row = connection
.query_row(
"SELECT title, repository_id, ref_selector FROM typed_tickets",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.unwrap();
assert_eq!(row, ("kept".into(), "main".into(), "develop".into()));
let preserved = connection
.query_row(
"SELECT
(SELECT COUNT(*) FROM typed_ticket_events),
(SELECT COUNT(*) FROM typed_ticket_event_references),
(SELECT COUNT(*) FROM typed_ticket_relations),
(SELECT COUNT(*) FROM typed_ticket_orchestration_plans),
(SELECT COUNT(*) FROM typed_ticket_artifacts)",
[],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
},
)
.unwrap();
assert_eq!(preserved, (1, 1, 1, 1, 1));
}
#[test]
fn v5_backfills_ticket_keys_and_v6_preserves_them_under_resource_key_schema() {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema_through(&connection, 4).unwrap();
connection.execute_batch(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit, created_at, updated_at
) VALUES
('workspace-1', 'later', 'later', 'Later', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z'),
('workspace-1', 'earlier', 'earlier', 'Earlier', 'open', 'task', 'medium', '', 'ready', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');"
).unwrap();
migrate_sqlite_ticket_schema_through(&connection, 5).unwrap();
let legacy_keys = connection
.prepare(
"SELECT resource_id, human_key FROM workspace_resource_human_keys
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
ORDER BY sequence",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert_eq!(
legacy_keys,
vec![
("earlier".into(), "T-1".into()),
("later".into(), "T-2".into())
]
);
let next: i64 = connection
.query_row(
"SELECT next_sequence FROM workspace_resource_human_key_counters
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(next, 3);
migrate_sqlite_ticket_schema(&connection).unwrap();
let resource_keys = connection
.prepare(
"SELECT resource_id, resource_key FROM workspace_resource_keys
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'
ORDER BY sequence",
)
.unwrap()
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
assert_eq!(resource_keys, legacy_keys);
assert_eq!(
connection
.query_row(
"SELECT next_sequence FROM workspace_resource_key_counters
WHERE workspace_id = 'workspace-1' AND resource_kind = 'ticket'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap(),
3
);
for legacy_table in [
"workspace_resource_human_keys",
"workspace_resource_human_key_counters",
] {
assert!(
connection
.query_row(
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
[legacy_table],
|_| Ok(()),
)
.optional()
.unwrap()
.is_none(),
"{legacy_table} still exists"
);
}
}
#[test]
fn upgrades_legacy_schema_without_repository_target_columns() {
let connection = Connection::open_in_memory().unwrap();
create_typed_ticket_tables(&connection).unwrap();
connection
.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES ('workspace-1', 'ticket-1', 'ticket-1', 'legacy', 'open',
'task', 'medium', 'body', 'ready', 1)",
[],
)
.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 (version, name, applied_at)
VALUES (1, 'create_typed_ticket_tables', '2026-08-10T00:00:00Z');",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
let columns = load_columns(&connection, "typed_tickets").unwrap();
assert!(columns.iter().any(|column| column.name == "repository_id"));
assert!(columns.iter().any(|column| column.name == "ref_selector"));
let title = connection
.query_row("SELECT title FROM typed_tickets", [], |row| {
row.get::<_, String>(0)
})
.unwrap();
assert_eq!(title, "legacy");
assert_eq!(load_applied_migrations(&connection).unwrap(), versions);
}
#[test]
@@ -1421,12 +855,32 @@ mod tests {
.unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported Ticket schema migration version 99")
);
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7);
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
#[test]
fn rejects_legacy_migration_marker() {
let connection = Connection::open_in_memory().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 (version, name, applied_at)
VALUES (6, 'rename_workspace_resource_keys', '2026-08-10T00:00:00Z');",
)
.unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 6 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
#[test]
@@ -1509,77 +963,6 @@ mod tests {
verify_sqlite_ticket_schema(&connection).unwrap();
}
#[test]
fn migration_rejects_constraint_drift_and_rolls_back_version_adoption() {
let connection = Connection::open_in_memory().unwrap();
connection
.execute_batch(
"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 (ticket_id, workspace_id)
);",
)
.unwrap();
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains("primary-key position"));
let migration_table_exists = connection
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ticket_schema_migrations'",
[],
|_| Ok(()),
)
.optional()
.unwrap()
.is_some();
assert!(!migration_table_exists);
}
#[test]
fn legacy_review_upgrade_preserves_prose_as_non_authoritative_comment() {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute("INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES ('workspace-1','ticket-1','ticket-1','title','open','task','medium','body','inprogress',1)",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
connection
.execute_batch(
"DROP TABLE workspace_resource_key_counters;
DROP TABLE workspace_resource_keys;
DELETE FROM ticket_schema_migrations WHERE version >= 3;",
)
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
assert_eq!(kind, "comment");
assert_eq!(status, None);
assert_eq!(
heading.as_deref(),
Some("Legacy review (non-authoritative)")
);
assert_eq!(body.as_deref(), Some("legacy evidence"));
let attributes:i64=connection.query_row("SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1'",[],|row|row.get(0)).unwrap();
assert_eq!(attributes, 1);
let legacy:String=connection.query_row("SELECT value FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND key='legacy_event_kind'",[],|row|row.get(0)).unwrap();
assert_eq!(legacy, "review");
}
#[test]
fn concurrent_migrators_converge_on_one_version_history() {
let directory = tempdir().unwrap();
@@ -1602,6 +985,6 @@ mod tests {
let connection = Connection::open(database).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 6);
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 1);
}
}
+1 -6
View File
@@ -228,7 +228,7 @@ worker_context_max_tokens = 100000
enabled = true
[feature.memory]
enabled = true
enabled = false
[feature.web]
enabled = true
@@ -241,11 +241,6 @@ enabled = true
authoring = true
thread = true
[memory]
extract_threshold = 50000
consolidation_threshold_files = 5
consolidation_threshold_bytes = 50000
[web]
enabled = true
+35 -1
View File
@@ -179,6 +179,30 @@ pub struct WorkingDirectoryRequest {
pub materialization: Option<RepositoryMaterializationContext>,
}
/// Backend-authorized request to freshly resolve one Repository provider ref.
///
/// Runtime executes this against the registered source itself rather than a Workdir
/// or Runtime cache. Secret material is fetched through `materialization` and never
/// appears in the result.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRefObservationRequest {
pub repository: WorkingDirectoryRepository,
pub selector: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub materialization: Option<RepositoryMaterializationContext>,
}
/// Provider-neutral proof of one freshly observed Repository ref.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRefObservation {
pub repository_id: String,
pub source_revision: u64,
pub source_fingerprint: String,
pub selector: String,
pub revision_ref: String,
pub observed_at_epoch_seconds: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryClaim {
pub working_directory_id: String,
@@ -250,6 +274,10 @@ pub struct CreateWorkerRequest {
}
/// Worker lifecycle status for the in-memory embedded runtime.
///
/// Run termination details are carried separately by the Worker protocol. In
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle
/// state of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerStatus {
@@ -257,7 +285,6 @@ pub enum WorkerStatus {
Running,
Paused,
Stopped,
Cancelled,
}
impl WorkerStatus {
@@ -266,6 +293,13 @@ impl WorkerStatus {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WorkerRestoreIntent {
Automatic,
Explicit,
}
/// Lightweight catalog row.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSummary {
+18
View File
@@ -1,4 +1,5 @@
use crate::catalog::{
RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle;
@@ -333,6 +334,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
))
}
fn observe_repository_ref(
&self,
_request: &RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
Err(WorkingDirectoryDiagnostic::rejected(
"repository_ref_provider_unavailable",
"Worker execution backend does not support Repository ref observation",
))
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
Vec::new()
}
@@ -501,6 +512,13 @@ impl WorkerExecutionBackendRef {
.authorize_working_directory_repository_access(request)
}
pub(crate) fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
self.backend.observe_repository_ref(request)
}
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.backend.list_working_directories()
}
+123 -47
View File
@@ -1,4 +1,6 @@
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
use crate::catalog::{
CreateWorkerRequest, WorkerRestoreIntent, WorkerStatus, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle;
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::error::RuntimeError;
@@ -13,7 +15,7 @@ use std::io::{BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 3;
const SCHEMA_VERSION: u32 = 4;
const RUNTIME_FILE: &str = "runtime.json";
const WORKERS_DIR: &str = "workers";
const WORKER_FILE: &str = "worker.json";
@@ -274,13 +276,24 @@ pub(crate) struct PersistedRuntimeState {
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedWorkerExecutionBinding {
pub(crate) run_generation: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedWorkerExecution {
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
pub(crate) restore_intent: WorkerRestoreIntent,
}
#[derive(Clone, Debug)]
pub(crate) struct PersistedWorkerRecord {
pub(crate) worker_ref: WorkerRef,
pub(crate) worker_id: WorkerId,
pub(crate) request: CreateWorkerRequest,
/// Last generation durably reserved for this Worker's execution.
pub(crate) run_generation: u64,
pub(crate) status: WorkerStatus,
pub(crate) execution: PersistedWorkerExecution,
pub(crate) workspace_id: Option<String>,
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
}
@@ -357,8 +370,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"),
)
})?;
let staging = migration_sibling(root, "schema-v3-staging")?;
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
let staging = migration_sibling(root, "schema-v4-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -384,11 +397,11 @@ fn plan_runtime_store_migration(
};
return Ok((plan, Vec::new()));
}
if !matches!(current_schema_version, 1 | 2) {
if current_schema_version != 3 {
return Err(runtime_store_corrupt(
&runtime_path,
format!(
"unsupported Runtime store schema version {schema_version}; expected 1, 2, or {SCHEMA_VERSION}"
"unsupported Runtime store schema version {schema_version}; expected 3 or {SCHEMA_VERSION}"
),
));
}
@@ -448,7 +461,7 @@ fn plan_runtime_store_migration(
let worker_id = name.parse::<WorkerId>().map_err(|_| {
runtime_store_corrupt(
&source_dir,
format!("schema-v2 Worker directory name must be a UUIDv7, found {name}"),
format!("pre-v4 Worker directory name must be a UUIDv7, found {name}"),
)
})?;
(worker_id, None, None)
@@ -610,7 +623,7 @@ fn migrate_worker_document(
snapshot_path: &Path,
) -> Result<serde_json::Value, RuntimeError> {
if source_schema_version == 1 {
return migrate_v1_worker_document(
document = migrate_v1_worker_document(
document,
mapping.ok_or_else(|| {
runtime_store_corrupt(
@@ -619,7 +632,7 @@ fn migrate_worker_document(
)
})?,
snapshot_path,
);
)?;
}
let object = document.as_object_mut().ok_or_else(|| {
runtime_store_corrupt(
@@ -627,10 +640,46 @@ fn migrate_worker_document(
"Worker snapshot must be an object".to_string(),
)
})?;
let run_generation = object
.remove("run_generation")
.map(|value| {
value.as_u64().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot run_generation must be an unsigned integer".to_string(),
)
})
})
.transpose()?
.filter(|generation| *generation > 0);
let legacy_execution = object.remove("execution");
if !object.contains_key("working_directory") {
if let Some(working_directory) = legacy_execution
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|execution| execution.get("working_directory"))
.cloned()
{
object.insert("working_directory".to_string(), working_directory);
}
}
object.insert(
"schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION),
);
object.insert(
"status".to_string(),
serde_json::Value::String("stopped".to_string()),
);
object.insert(
"execution".to_string(),
serde_json::json!({
"binding": run_generation.map(|run_generation| {
serde_json::json!({ "run_generation": run_generation })
}),
"restore_intent": "explicit",
}),
);
Ok(document)
}
@@ -710,8 +759,8 @@ fn migrate_worker_aggregate_document(
.get_mut("resolved_manifest_snapshot")
.filter(|snapshot| !snapshot.is_null())
{
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot.clone()).map_err(|error| {
let mut manifest = manifest::read_persisted_worker_manifest_snapshot(snapshot.clone())
.map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode Worker aggregate resolved manifest snapshot: {error}"),
@@ -726,20 +775,14 @@ fn migrate_worker_aggregate_document(
),
));
}
snapshot
.as_object_mut()
.and_then(|manifest| manifest.get_mut("worker"))
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
manifest.worker.name = expected_name.clone();
*snapshot =
manifest::write_persisted_worker_manifest_snapshot(&manifest).map_err(|error| {
runtime_store_corrupt(
metadata_path,
"Worker aggregate resolved manifest is missing worker metadata".to_string(),
format!("encode migrated Worker aggregate resolved manifest: {error}"),
)
})?
.insert(
"name".to_string(),
serde_json::Value::String(expected_name.clone()),
);
})?;
}
metadata.insert(
"worker_name".to_string(),
@@ -760,8 +803,8 @@ fn migrate_worker_aggregate_document(
));
}
if let Some(snapshot) = metadata.resolved_manifest_snapshot {
let manifest: manifest::WorkerManifest =
serde_json::from_value(snapshot).map_err(|error| {
let manifest =
manifest::read_persisted_worker_manifest_snapshot(snapshot).map_err(|error| {
runtime_store_corrupt(
metadata_path,
format!("decode migrated Worker aggregate resolved manifest: {error}"),
@@ -1005,8 +1048,8 @@ fn migrate_runtime_store(
if !plan.migration_required {
return Ok(plan);
}
let staging = migration_sibling(root, "schema-v3-staging")?;
let backup = migration_sibling(root, "pre-schema-v3-backup")?;
let staging = migration_sibling(root, "schema-v4-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?;
if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt(
root,
@@ -1236,22 +1279,12 @@ struct WorkerSnapshot {
worker_ref: WorkerRef,
worker_id: WorkerId,
request: CreateWorkerRequest,
#[serde(default)]
run_generation: u64,
status: WorkerStatus,
execution: PersistedWorkerExecution,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
working_directory: Option<WorkingDirectoryStatus>,
/// One-way migration input for schema-v1 snapshots. New snapshots never
/// write the removed execution projection.
#[serde(default, rename = "execution", skip_serializing)]
legacy_execution: Option<LegacyWorkerExecutionProjection>,
}
#[derive(Clone, Debug, Deserialize)]
struct LegacyWorkerExecutionProjection {
#[serde(default)]
working_directory: Option<WorkingDirectoryStatus>,
}
impl WorkerSnapshot {
@@ -1261,10 +1294,10 @@ impl WorkerSnapshot {
worker_ref: worker.worker_ref.clone(),
worker_id: worker.worker_id.clone(),
request: worker.request.clone(),
run_generation: worker.run_generation,
status: worker.status,
execution: worker.execution.clone(),
workspace_id: worker.workspace_id.clone(),
working_directory: worker.working_directory.clone(),
legacy_execution: None,
}
}
@@ -1289,6 +1322,51 @@ impl WorkerSnapshot {
),
});
}
match (self.status, self.execution.restore_intent) {
(status, WorkerRestoreIntent::Automatic) if status.is_active() => {
let Some(binding) = self.execution.binding.as_ref() else {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: "automatic restore intent requires an execution binding"
.to_string(),
});
};
if binding.run_generation == 0 {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: "execution binding run_generation must be greater than zero"
.to_string(),
});
}
}
(WorkerStatus::Stopped, WorkerRestoreIntent::Explicit) => {
if self
.execution
.binding
.as_ref()
.is_some_and(|binding| binding.run_generation == 0)
{
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: "execution binding run_generation must be greater than zero"
.to_string(),
});
}
}
_ => {
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: format!(
"worker status {:?} conflicts with restore intent {:?}",
self.status, self.execution.restore_intent
),
});
}
}
Ok(())
}
@@ -1303,12 +1381,10 @@ impl WorkerSnapshot {
worker_ref: self.worker_ref,
worker_id: self.worker_id,
request: self.request,
run_generation: self.run_generation,
status: self.status,
execution: self.execution,
workspace_id,
working_directory: self.working_directory.or_else(|| {
self.legacy_execution
.and_then(|execution| execution.working_directory)
}),
working_directory: self.working_directory,
}
}
}
+85 -17
View File
@@ -11,9 +11,9 @@ use crate::auth::{
verify_capability_token,
};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
};
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError;
@@ -208,6 +208,7 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories/repository-access",
post(authorize_working_directory_repository_access),
)
.route("/v1/repository-refs/observe", post(observe_repository_ref))
.route(
"/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session),
@@ -583,6 +584,31 @@ async fn authorize_working_directory_repository_access(
}))
}
async fn observe_repository_ref(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<RepositoryRefObservationRequest>, JsonRejection>,
) -> RestResult<crate::catalog::RepositoryRefObservation> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if request
.materialization
.as_ref()
.is_some_and(|materialization| materialization.workspace_id != auth.workspace_id)
{
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"repository_ref_observation_workspace_mismatch",
"Repository ref observation authority does not match the authenticated Workspace",
));
}
let observation = state
.runtime
.observe_repository_ref_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(observation))
}
async fn list_working_directories(
State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
@@ -1750,7 +1776,10 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST {
return Some("workers:create");
}
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
if (path == "/v1/working-directories/repository-access"
|| path == "/v1/repository-refs/observe")
&& *method == Method::POST
{
return Some("workdirs:operate");
}
if path.starts_with("/v1/workdir-sessions")
@@ -1941,6 +1970,33 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
{
StatusCode::NOT_FOUND
}
RuntimeError::WorkingDirectory(diagnostic)
if matches!(
diagnostic.code.as_str(),
"repository_ref_provider_unavailable"
| "repository_ref_provider_timeout"
| "repository_access_provider_unavailable"
) =>
{
StatusCode::SERVICE_UNAVAILABLE
}
RuntimeError::WorkingDirectory(diagnostic)
if matches!(
diagnostic.code.as_str(),
"repository_ref_provider_auth_failed"
| "repository_access_credential_expired"
| "repository_access_credential_unavailable"
| "repository_access_credential_unauthorized"
| "repository_access_credential_invalid"
) =>
{
StatusCode::FORBIDDEN
}
RuntimeError::WorkingDirectory(diagnostic)
if diagnostic.code == "repository_ref_not_found" =>
{
StatusCode::NOT_FOUND
}
RuntimeError::RuntimeStopped
| RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. }
@@ -1951,8 +2007,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
| RuntimeError::InvalidInitialInputKind { .. }
| RuntimeError::ConfigBundleDigestMismatch { .. }
| RuntimeError::InvalidProfileSelector { .. }
| RuntimeError::UnsupportedConfigDeclaration { .. }
| RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
| RuntimeError::UnsupportedConfigDeclaration { .. } => StatusCode::BAD_REQUEST,
RuntimeError::WorkingDirectory(_) => StatusCode::BAD_REQUEST,
RuntimeError::StoreIo { .. }
| RuntimeError::StoreMissing { .. }
| RuntimeError::StoreCorrupt { .. }
@@ -2424,6 +2480,10 @@ mod tests {
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate")
@@ -2934,17 +2994,25 @@ mod tests {
#[test]
fn workdir_runtime_errors_preserve_diagnostic_code() {
let error =
RuntimeError::WorkingDirectory(crate::working_directory::WorkingDirectoryDiagnostic {
code: "working_directory_not_found".to_string(),
message: "working directory missing-workdir was not found".to_string(),
});
assert_eq!(status_for_runtime_error(&error), StatusCode::NOT_FOUND);
assert_eq!(
code_for_runtime_error(&error),
"working_directory_not_found"
);
let cases = [
("working_directory_not_found", StatusCode::NOT_FOUND),
(
"repository_ref_provider_timeout",
StatusCode::SERVICE_UNAVAILABLE,
),
("repository_ref_provider_auth_failed", StatusCode::FORBIDDEN),
("repository_ref_not_found", StatusCode::NOT_FOUND),
];
for (code, expected_status) in cases {
let error = RuntimeError::WorkingDirectory(
crate::working_directory::WorkingDirectoryDiagnostic {
code: code.to_string(),
message: "bounded diagnostic".to_string(),
},
);
assert_eq!(status_for_runtime_error(&error), expected_status);
assert_eq!(code_for_runtime_error(&error), code);
}
}
}
+4 -4
View File
@@ -1028,14 +1028,14 @@ mod tests {
}
#[test]
fn migration_dry_run_accepts_real_v1_document_without_workers_field() {
fn migration_dry_run_accepts_previous_schema_document_without_workers_field() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write(
root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"schema_version": 3,
"display_name": "local",
"backend": "fs_store",
"status": "running",
@@ -1067,14 +1067,14 @@ mod tests {
}
#[test]
fn migration_dry_run_rejects_v1_document_that_cannot_decode_as_v3() {
fn migration_dry_run_rejects_previous_schema_document_that_cannot_decode_as_v4() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("runtime");
std::fs::create_dir_all(root.join("workers")).unwrap();
std::fs::write(
root.join("runtime.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"schema_version": 3,
"display_name": "local",
"backend": "fs_store",
"status": 3,
-1
View File
@@ -43,7 +43,6 @@ pub struct RuntimeSummary {
pub worker_count: usize,
pub active_worker_count: usize,
pub stopped_worker_count: usize,
pub cancelled_worker_count: usize,
pub diagnostic_count: usize,
#[serde(default = "unknown_platform_component")]
pub os: String,
+40 -8
View File
@@ -303,7 +303,12 @@ impl FsWorkerRetentionProvider {
));
continue;
}
match self.inventory(workspace_id, runtime_id, worker_id, snapshot.run_generation) {
match self.inventory(
workspace_id,
runtime_id,
worker_id,
snapshot.run_generation(),
) {
Ok(item) => workers.push(item),
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
&bounded_id,
@@ -388,10 +393,11 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
if worker.workspace_id.as_deref() != Some(workspace_id) {
return Err(RuntimeError::WorkerNotFound { worker_id });
}
if worker.run_generation != run_generation {
let current_run_generation = worker.run_generation();
if current_run_generation != run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention inventory expected generation {run_generation}, current generation is {}",
worker.run_generation
current_run_generation
)));
}
let session_dir = worker_dir.join("session");
@@ -498,10 +504,11 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
worker_id: request.worker_id,
});
}
if snapshot.run_generation != request.expected_run_generation {
let run_generation = snapshot.run_generation();
if run_generation != request.expected_run_generation {
return Err(RuntimeError::InvalidRequest(format!(
"Worker retention plan expected generation {}, current generation is {}",
request.expected_run_generation, snapshot.run_generation
request.expected_run_generation, run_generation
)));
}
@@ -568,10 +575,29 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
struct WorkerGenerationSnapshot {
#[serde(default)]
workspace_id: Option<String>,
#[serde(default)]
execution: WorkerGenerationExecution,
}
#[derive(Deserialize)]
struct WorkerGenerationExecution {
binding: Option<WorkerGenerationBinding>,
}
#[derive(Deserialize)]
struct WorkerGenerationBinding {
run_generation: u64,
}
impl WorkerGenerationSnapshot {
fn run_generation(&self) -> u64 {
self.execution
.binding
.as_ref()
.map(|binding| binding.run_generation)
.unwrap_or(0)
}
}
#[derive(Deserialize)]
struct CanonicalSessionManifest {
session_id: String,
@@ -1264,7 +1290,10 @@ mod tests {
let worker = root.join("workers").join(worker_id.to_string());
write_json(
&worker.join("worker.json"),
&serde_json::json!({"workspace_id": "workspace-a", "run_generation": generation}),
&serde_json::json!({
"workspace_id": "workspace-a",
"execution": {"binding": {"run_generation": generation}}
}),
);
write_json(
&worker.join("session/session.json"),
@@ -1462,7 +1491,10 @@ mod tests {
.join("workers")
.join(other_worker.to_string())
.join("worker.json"),
&serde_json::json!({"workspace_id": "other-workspace", "run_generation": 1}),
&serde_json::json!({
"workspace_id": "other-workspace",
"execution": {"binding": {"run_generation": 1}}
}),
);
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
fs::write(
File diff suppressed because it is too large Load Diff
+88 -6
View File
@@ -20,6 +20,7 @@ use crate::auth::{
};
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::execution::{
@@ -745,9 +746,15 @@ fn bind_workspace_memory_settings(
));
}
manifest
.feature
.memory
.get_or_insert_with(manifest::MemoryConfig::default)
.bind_workspace_settings(snapshot);
.bind_workspace_settings(snapshot.clone())
.map_err(str::to_string)?;
manifest
.feature
.memory
.validate_execution()
.map_err(str::to_string)?;
Ok(())
}
@@ -758,10 +765,18 @@ fn validate_worker_memory_settings(
let Some(expected) = request.memory_settings.as_ref() else {
return Ok(());
};
let actual = manifest
manifest
.feature
.memory
.as_ref()
.and_then(manifest::MemoryConfig::workspace_settings)
.validate_execution()
.map_err(str::to_string)?;
if !manifest.feature.memory.profile.enabled {
return Ok(());
}
let actual = manifest
.feature
.memory
.workspace_settings()
.ok_or_else(|| {
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
})?;
@@ -1645,6 +1660,19 @@ where
materializer.authorize_repository_access(request)
}
fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
WorkingDirectoryDiagnostic::rejected(
"repository_ref_provider_unavailable",
"Repository ref observation requested, but no materializer is configured for this Runtime backend",
)
})?;
materializer.observe_repository_ref(request)
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.working_directory_materializer
.as_ref()
@@ -3151,7 +3179,7 @@ mod tests {
Some(session_store::WorkerActiveSegmentRef::pending_segment(
session_id,
)),
Some(serde_json::to_value(&manifest).unwrap()),
Some(manifest::write_persisted_worker_manifest_snapshot(&manifest).unwrap()),
)
.unwrap();
@@ -3665,6 +3693,60 @@ mod tests {
assert_eq!(call_count.load(Ordering::SeqCst), 3);
}
#[test]
fn stopped_runtime_worker_can_restore_and_accept_input() {
let client = MockClient::new(simple_text_events());
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
};
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime
.create_worker(create_request("restore-after-stop"))
.unwrap();
runtime.stop_worker(&detail.worker_ref, None).unwrap();
assert_eq!(
runtime.worker_detail(&detail.worker_ref).unwrap().status,
crate::catalog::WorkerStatus::Stopped
);
assert!(
!backend
.workers
.lock()
.unwrap()
.contains_key(&detail.worker_ref)
);
runtime.restore_worker(&detail.worker_ref).unwrap();
assert_eq!(
runtime.worker_detail(&detail.worker_ref).unwrap().status,
crate::catalog::WorkerStatus::Idle
);
assert!(
backend
.workers
.lock()
.unwrap()
.contains_key(&detail.worker_ref)
);
runtime
.send_input(&detail.worker_ref, WorkerInput::user("continue"))
.unwrap();
}
#[test]
fn stopping_and_deleting_worker_preserves_bound_working_directory() {
let client = MockClient::new(simple_text_events());
+389 -11
View File
@@ -1,5 +1,6 @@
use crate::catalog::{
MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
MaterializerKind, RepositoryRefObservation, RepositoryRefObservationRequest,
RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
@@ -196,6 +197,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic>;
fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic>;
fn bind_working_directory(
&self,
working_directory_id: &str,
@@ -943,6 +949,72 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer {
self.cache_repository_access(&request.working_directory_id, ssh)
}
fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, WorkingDirectoryDiagnostic> {
let selector = request.selector.trim();
validate_exact_branch_selector(selector)?;
let working_request = WorkingDirectoryRequest {
repository: request.repository.clone(),
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: None,
materialization: request.materialization.clone(),
};
Self::validate_request(&working_request)?;
let access = RepositoryCommandAccess::prepare(&self.runtime_root, &working_request)?;
let mut command = repository_git_command(&working_request, access.as_ref());
command.args([
"ls-remote",
"--exit-code",
"--refs",
request.repository.source.uri.as_str(),
selector,
]);
let output = run_repository_git_stdout(command, request.repository.source.kind)?;
let mut lines = output.lines();
let line = lines.next().ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"repository_ref_not_found",
"Repository provider did not return the requested ref",
)
})?;
if lines.next().is_some() {
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
"Repository provider returned an ambiguous ref observation",
));
}
let (revision_ref, observed_selector) = line.split_once('\t').ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
"Repository provider returned an invalid ref observation",
)
})?;
if observed_selector != selector
|| !matches!(revision_ref.len(), 40 | 64)
|| !revision_ref.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
"Repository provider returned an invalid ref observation",
));
}
Ok(RepositoryRefObservation {
repository_id: request.repository.id.clone(),
source_revision: request.repository.source_revision,
source_fingerprint: request.repository.source_fingerprint.clone(),
selector: selector.to_string(),
revision_ref: revision_ref.to_ascii_lowercase(),
observed_at_epoch_seconds: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
})
}
fn bind_working_directory(
&self,
working_directory_id: &str,
@@ -1135,11 +1207,17 @@ impl RepositorySshAgent {
working_directory_id: &str,
access: &RepositorySshMaterializationAccess,
) -> Result<Self, WorkingDirectoryDiagnostic> {
let root = runtime_root.join(".repository-agents").join(format!(
"{}-{}",
sanitize_path_component(working_directory_id),
next_working_directory_id("agent")
));
let mut digest = Sha256::new();
digest.update(working_directory_id.as_bytes());
digest.update([0]);
digest.update(next_working_directory_id("agent").as_bytes());
let agent_id = digest
.finalize()
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let root = runtime_root.join(".repository-agents").join(agent_id);
fs::create_dir_all(&root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_agent_failed",
@@ -1790,11 +1868,20 @@ impl RepositoryCommandAccess {
repository_id: &str,
ssh: &RepositorySshMaterializationAccess,
) -> Result<Self, WorkingDirectoryDiagnostic> {
let root = runtime_root.join(REPOSITORY_ACCESS_DIR).join(format!(
"{}-{}",
sanitize_path_component(operation_id),
next_working_directory_id(repository_id)
));
let mut digest = Sha256::new();
digest.update(operation_id.as_bytes());
digest.update([0]);
digest.update(repository_id.as_bytes());
digest.update([0]);
digest.update(next_working_directory_id("access").as_bytes());
let access_id = digest
.finalize()
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
// Keep operation-scoped Unix socket paths below sockaddr_un::sun_path on deep runtime roots.
let root = runtime_root.join(REPOSITORY_ACCESS_DIR).join(access_id);
fs::create_dir_all(&root).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_setup_failed",
@@ -2022,6 +2109,146 @@ fn repository_git_command(
command
}
fn validate_exact_branch_selector(selector: &str) -> Result<(), WorkingDirectoryDiagnostic> {
validate_selector(selector).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"repository_ref_selector_invalid",
"Repository ref observation requires a valid exact branch selector",
)
})?;
if !selector.starts_with("refs/heads/") {
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_selector_invalid",
"Repository ref observation requires an exact branch selector",
));
}
let status = Command::new("git")
.args(["check-ref-format", selector])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|_| {
WorkingDirectoryDiagnostic::new(
"repository_ref_provider_unavailable",
"Git ref validation could not be started",
)
})?;
if !status.success() {
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_selector_invalid",
"Repository ref observation requires a valid exact branch selector",
));
}
Ok(())
}
fn read_bounded_command_output(mut reader: impl Read) -> Vec<u8> {
const MAX_CAPTURE_BYTES: usize = 8192;
let mut captured = Vec::new();
let mut chunk = [0_u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => {
let remaining = MAX_CAPTURE_BYTES.saturating_sub(captured.len());
captured.extend_from_slice(&chunk[..read.min(remaining)]);
}
}
}
captured
}
fn run_repository_git_stdout(
mut command: Command,
source_kind: workspace_api::RepositorySourceKind,
) -> Result<String, WorkingDirectoryDiagnostic> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"repository_ref_provider_unavailable",
"Repository provider operation could not be started",
)
})?;
let stdout = child.stdout.take().ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"repository_ref_provider_unavailable",
"Repository provider response could not be captured",
)
})?;
let stderr = child.stderr.take().ok_or_else(|| {
WorkingDirectoryDiagnostic::new(
"repository_ref_provider_unavailable",
"Repository provider diagnostic could not be captured",
)
})?;
let stdout_reader = std::thread::spawn(move || read_bounded_command_output(stdout));
let stderr_reader = std::thread::spawn(move || read_bounded_command_output(stderr));
let started = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"repository_ref_provider_unavailable",
"Repository provider operation status could not be observed",
)
})? {
break status;
}
if started.elapsed() >= REPOSITORY_COMMAND_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
let _ = stderr_reader.join();
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_provider_timeout",
"Repository provider operation exceeded the Runtime time limit",
));
}
std::thread::sleep(Duration::from_millis(25));
};
let stdout = stdout_reader.join().unwrap_or_default();
let stderr = stderr_reader.join().unwrap_or_default();
if status.success() {
return String::from_utf8(stdout).map_err(|_| {
WorkingDirectoryDiagnostic::new(
"repository_ref_response_invalid",
"Repository provider returned a non-UTF-8 ref observation",
)
});
}
if status.code() == Some(2) {
return Err(WorkingDirectoryDiagnostic::new(
"repository_ref_not_found",
"Repository provider did not return the requested ref",
));
}
let diagnostic = String::from_utf8_lossy(&stderr).to_ascii_lowercase();
let auth_failed = source_kind.is_remote()
&& [
"authentication failed",
"permission denied",
"could not read username",
"publickey",
]
.iter()
.any(|marker| diagnostic.contains(marker));
Err(WorkingDirectoryDiagnostic::new(
if auth_failed {
"repository_ref_provider_auth_failed"
} else {
"repository_ref_provider_unavailable"
},
if auth_failed {
"Repository provider rejected the operation-scoped authentication"
} else {
"Repository provider operation failed"
},
))
}
fn run_repository_git(
mut command: Command,
code: &'static str,
@@ -2493,6 +2720,157 @@ mod tests {
WorkerRef::new(WorkerId::from_legacy_u64(sequence))
}
#[test]
fn repository_ref_observation_reads_the_provider_fresh() {
let repo = create_clean_repo();
git(repo.path(), &["branch", "published"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
let repository = request(repo.path()).repository;
let observation_request = RepositoryRefObservationRequest {
repository,
selector: "refs/heads/published".to_string(),
materialization: None,
};
let first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(
first.revision_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
);
fs::write(repo.path().join("second.txt"), "second\n").unwrap();
git(repo.path(), &["add", "second.txt"]);
git(repo.path(), &["commit", "-m", "second"]);
git(repo.path(), &["branch", "-f", "published"]);
let second = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_ne!(first.revision_ref, second.revision_ref);
assert_eq!(
second.revision_ref,
git_stdout(repo.path(), ["rev-parse", "published"]).unwrap()
);
}
#[test]
fn repository_ref_observation_ignores_unpublished_and_stale_workdir_or_cache_refs() {
let seed = create_clean_repo();
let layout = tempfile::tempdir().unwrap();
let provider = layout.path().join("provider.git");
git(
layout.path(),
&[
"clone",
"--bare",
seed.path().to_str().unwrap(),
provider.to_str().unwrap(),
],
);
let cache = layout.path().join("cache");
git(
layout.path(),
&["clone", provider.to_str().unwrap(), cache.to_str().unwrap()],
);
let workdir = layout.path().join("workdir");
git(
layout.path(),
&[
"clone",
provider.to_str().unwrap(),
workdir.to_str().unwrap(),
],
);
git(&workdir, &["config", "user.name", "Yoi Test"]);
git(&workdir, &["config", "user.email", "yoi@example.com"]);
git(&workdir, &["switch", "-c", "published-source"]);
fs::write(workdir.join("source.txt"), "first\n").unwrap();
git(&workdir, &["add", "source.txt"]);
git(&workdir, &["commit", "-m", "source first"]);
let runtime_root = tempfile::tempdir().unwrap();
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
let repository = request(&provider).repository;
let observation_request = RepositoryRefObservationRequest {
repository,
selector: "refs/heads/published-source".to_string(),
materialization: None,
};
assert_eq!(
materializer
.observe_repository_ref(&observation_request)
.unwrap_err()
.code,
"repository_ref_not_found"
);
git(
&workdir,
&["push", "origin", "HEAD:refs/heads/published-source"],
);
let first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
fs::write(workdir.join("source.txt"), "second\n").unwrap();
git(&workdir, &["add", "source.txt"]);
git(&workdir, &["commit", "-m", "source second"]);
let unpublished_second = git_stdout(&workdir, ["rev-parse", "HEAD"]).unwrap();
let still_first = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(still_first.revision_ref, first.revision_ref);
assert_ne!(still_first.revision_ref, unpublished_second);
git(
&workdir,
&["push", "origin", "HEAD:refs/heads/published-source"],
);
let second = materializer
.observe_repository_ref(&observation_request)
.unwrap();
assert_eq!(second.revision_ref, unpublished_second);
assert_ne!(second.revision_ref, first.revision_ref);
assert_ne!(
git_stdout(&cache, ["rev-parse", "HEAD"]).unwrap(),
second.revision_ref
);
}
#[test]
fn repository_ref_observation_rejects_missing_and_non_branch_selectors() {
let repo = create_clean_repo();
let runtime_root = tempfile::tempdir().unwrap();
let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path());
let repository = request(repo.path()).repository;
let missing = materializer
.observe_repository_ref(&RepositoryRefObservationRequest {
repository: repository.clone(),
selector: "refs/heads/not-published".to_string(),
materialization: None,
})
.unwrap_err();
assert_eq!(missing.code, "repository_ref_not_found");
let non_branch = materializer
.observe_repository_ref(&RepositoryRefObservationRequest {
repository: repository.clone(),
selector: "HEAD".to_string(),
materialization: None,
})
.unwrap_err();
assert_eq!(non_branch.code, "repository_ref_selector_invalid");
let wildcard = materializer
.observe_repository_ref(&RepositoryRefObservationRequest {
repository,
selector: "refs/heads/release/*".to_string(),
materialization: None,
})
.unwrap_err();
assert_eq!(wildcard.code, "repository_ref_selector_invalid");
}
#[test]
fn local_git_repo_materializes_detached_worktree_under_runtime_root() {
let repo = create_clean_repo();
+78 -19
View File
@@ -22,7 +22,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use agen::Item;
use agen::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
use agen::interceptor::{
Interceptor, InterceptorResult, PreLlmRequestContext, PreRequestAction, PreToolAction,
ToolCallInfo,
};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
use async_trait::async_trait;
use serde::Deserialize;
@@ -397,15 +400,19 @@ impl CompactWorkerInterceptor {
}
#[async_trait]
impl Interceptor for CompactWorkerInterceptor {
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, A>,
) -> InterceptorResult<PreRequestAction> {
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 {
return PreRequestAction::Cancel(format!(
return Ok(PreRequestAction::Cancel(format!(
"compact worker input occupancy exceeded {} tokens",
self.max_input_tokens
));
)));
}
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
@@ -413,25 +420,28 @@ impl Interceptor for CompactWorkerInterceptor {
.store(remaining, Ordering::Release);
if let Some(item) = self.maybe_emit_warning(remaining) {
self.usage_tracker.note_request(context.len() + 1);
return PreRequestAction::ContinueWith(vec![item]);
return Ok(PreRequestAction::ContinueWith(vec![item]));
}
self.usage_tracker.note_request(context.len());
PreRequestAction::Continue
Ok(PreRequestAction::Continue)
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, A>,
) -> InterceptorResult<PreToolAction> {
if self.final_reserve_tokens == 0 || info.call.name == "write_summary" {
return PreToolAction::Continue;
return Ok(PreToolAction::Continue);
}
let remaining = self.last_remaining_tokens.load(Ordering::Acquire);
if remaining > self.final_reserve_tokens {
return PreToolAction::Continue;
return Ok(PreToolAction::Continue);
}
PreToolAction::SyntheticResult(ToolResult::error(
Ok(PreToolAction::SyntheticResult(ToolResult::error(
info.call.id.clone(),
"compact worker final reserve reached; do not perform more exploratory tool reads. Call `write_summary` now.",
))
)))
}
}
@@ -467,13 +477,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
@@ -481,7 +505,14 @@ mod tests {
// Two 100-token requests would exceed a cumulative 150-token cap, but
// current occupancy is still the latest 100-token measurement.
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
}
@@ -503,13 +534,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::ContinueWith(items)
if items.len() == 1 && items[0].as_text().unwrap_or_default().contains("write_summary")
));
@@ -523,13 +568,27 @@ mod tests {
let mut context = vec![Item::user_message("hello")];
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
tracker.record_usage(&make_usage(100));
assert!(matches!(
interceptor.pre_llm_request(&mut context).await,
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Cancel(message) if message.contains("occupancy")
));
}
+48 -43
View File
@@ -197,8 +197,8 @@ async fn finish_controller_run<C, St>(
{
// history / user_segments are no longer mirrored on WorkerSharedState —
// clients reconstruct them from `Event::Snapshot` + live
// `Event::Entry` deliveries driven by the session-log sink. We
// flip the status and kick post-run memory jobs here.
// `Event::Entry` deliveries driven by the session-log sink. The
// lifecycle hook/task registry observes the terminal commit separately.
//
// In-flight blocks are run-local streaming state, not durable transcript.
// Any block not cleared by a committed AssistantItem must be discarded at
@@ -206,7 +206,6 @@ async fn finish_controller_run<C, St>(
// partial text/tool arguments after newer entries.
worker.clear_in_flight_events();
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
worker.spawn_post_run_memory_jobs();
}
/// Pending turn launch staged by an event handler for the next outer-loop
@@ -938,7 +937,6 @@ where
let local_filesystem = worker.local_working_directory().cloned();
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
let task_feature = worker.task_feature();
let memory_config = worker.manifest().memory.clone();
let web_config = worker.manifest().web.clone();
let mcp_config = worker.manifest().mcp.clone();
let spawner_name = worker.manifest().worker.name.clone();
@@ -995,6 +993,41 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts().load_full(),
)
.await?;
let memory_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
(
plan.resident_summary.clone(),
plan.system_prompt_override.clone(),
)
});
let memory_lifecycle_config = memory_install_plan
.as_ref()
.map(|plan| plan.resolved_config.clone());
if let Some(plan) = memory_install_plan {
feature_registry.add_module(plan.module);
}
if let Some(memory_config) = memory_lifecycle_config
&& let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
worker.manifest_lifecycle_features_enabled(),
memory_config,
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
worker.workspace_client_handle(),
spawner_manifest.clone(),
worker.llm_client_handle(),
prompts.clone(),
spawner_workspace_context.clone(),
worker.working_event_sender(),
)?
{
feature_registry.add_module(memory_lifecycle);
}
if sub_worker_enabled && !worker_enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
@@ -1148,38 +1181,6 @@ where
}
}
// Memory tools require explicit feature exposure. Workspace memory access
// is authority-bound to the Backend Workspace API; the Worker must not
// register local filesystem memory tools even when it has local cwd/root
// authority for shell/file tools.
if feature_config.memory.enabled {
let _mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
if workspace_client.is_available() && workspace_client.workspace_id().is_some() {
let definitions = if feature_config.memory.staging {
crate::feature::builtin::memory::workspace_http_memory_consolidation_tools(
workspace_client.clone(),
)
} else {
crate::feature::builtin::memory::workspace_http_memory_tools(
workspace_client.clone(),
)
};
for definition in definitions {
engine.register_tool(definition);
}
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"memory tools require Backend Workspace API authority",
));
}
}
let mut observation_providers: Vec<
Arc<dyn crate::feature::builtin::worker_observation::WorkerObservationProvider>,
> = Vec::new();
@@ -1235,6 +1236,9 @@ where
),
));
}
if let Some((resident_summary, system_prompt_override)) = memory_prompt_contribution {
worker.install_system_prompt_contribution(resident_summary, system_prompt_override);
}
if let Some(tracker) = tracker {
worker.attach_tracker(tracker);
}
@@ -1590,7 +1594,9 @@ async fn controller_loop<C, St>(
&working_event_tx,
target,
expected_head_entries,
) {
)
.await
{
worker.clear_in_flight_events();
shared_state.set_status(WorkerStatus::Idle);
let _ = working_event_tx.send(Event::Status {
@@ -1711,10 +1717,9 @@ async fn controller_loop<C, St>(
tracing::warn!(%error, "Worker runtime socket cleanup failed");
}
// Background memory jobs own extract/consolidate workers after a
// turn completes. Join them before closing the Workdir session so no
// Worker-owned task can outlive its operation attachment.
worker.wait_for_memory_jobs().await;
// Feature callbacks and tasks share the Worker scope. Stop them before
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
worker.stop_feature_runtime("controller shutdown").await;
if let Some(session) = worker.workdir_session()
&& let Err(error) = session.close().await
@@ -1993,7 +1998,7 @@ where
}
}
fn apply_rewind<C, St>(
async fn apply_rewind<C, St>(
worker: &mut Worker<C, St>,
working_event_tx: &broadcast::Sender<Event>,
target: RewindTargetId,
@@ -2003,7 +2008,7 @@ where
C: LlmClient + 'static,
St: Store,
{
match worker.rewind_to(target, expected_head_entries) {
match worker.rewind_to(target, expected_head_entries).await {
Ok(applied) => {
let session =
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
+392 -148
View File
@@ -23,7 +23,14 @@ use agen::tool::ToolDefinition;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::hook::{Hook, HookRegistryBuilder, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall};
use crate::hook::{
BeforeSessionRewrite, Hook, HookExecutionPolicy, HookRegistryBuilder, OnPromptSubmit,
OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall, RunCommitted, RunExit, WorkerStopping,
};
use background::{
BackgroundTaskSpec, FeatureBackgroundTask, FeatureBackgroundTaskRegistry,
FeatureBackgroundTaskRegistryBuilder,
};
/// Stable source-qualified identifier for a feature module.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
@@ -253,10 +260,15 @@ pub enum FeatureRuntimeKind {
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FeatureHookPoint {
PreRequest,
PromptSubmit,
PreLlmRequest,
PreToolCall,
ToolResult,
TurnEnd,
PostToolCall,
AssistantTurnEnd,
RunExit,
RunCommitted,
BeforeSessionRewrite,
WorkerStopping,
}
/// Serializable declaration of a tool contribution. The executable factory is
@@ -379,16 +391,17 @@ impl FeatureInstructionContribution {
}
}
/// Background task lifecycle phase represented by this registry slice.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// Background tasks are always Worker-managed and execute inside the owning
/// feature scope. Report-only and detached host-managed declarations are not
/// accepted by the current contract.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundTaskLifecycle {
DescriptorOnly,
HostManaged,
WorkerManaged,
}
/// Declaration for a feature-provided background task.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// Declaration for a feature-provided executable background task.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct BackgroundTaskDeclaration {
pub name: String,
pub description: String,
@@ -396,11 +409,11 @@ pub struct BackgroundTaskDeclaration {
}
impl BackgroundTaskDeclaration {
pub fn descriptor_only(name: impl Into<String>, description: impl Into<String>) -> Self {
pub fn worker_managed(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
lifecycle: BackgroundTaskLifecycle::DescriptorOnly,
lifecycle: BackgroundTaskLifecycle::WorkerManaged,
}
}
}
@@ -772,6 +785,15 @@ impl FeatureInstallReport {
}
}
fn clear_installed_contributions(&mut self) {
self.installed = false;
self.installed_tools.clear();
self.installed_hooks.clear();
self.installed_instructions.clear();
self.declared_background_tasks.clear();
self.provided_services.clear();
}
fn mark_skipped(
&mut self,
kind: FeatureContributionKind,
@@ -881,46 +903,6 @@ fn reject_undeclared_contribution(
error
}
/// Model-visible durable notification sink skeleton. The first slice exposes
/// the boundary without implementing a new event channel.
pub struct FeatureNotificationSink<'a> {
report: &'a mut FeatureInstallReport,
}
impl FeatureNotificationSink<'_> {
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
let message = message.into();
self.report.diagnostics.push(FeatureDiagnostic::warning(format!(
"model notification requested during feature installation but no durable Notify host is attached: {message}"
)));
self.report.mark_skipped(
FeatureContributionKind::Notification,
"notify_model",
"durable Notify/SystemItem host is not connected during feature installation",
);
Ok(())
}
}
/// Transient human-facing alert sink skeleton.
pub struct FeatureAlertSink<'a> {
report: &'a mut FeatureInstallReport,
}
impl FeatureAlertSink<'_> {
pub fn alert(&mut self, message: impl Into<String>) {
let message = message.into();
self.report
.diagnostics
.push(FeatureDiagnostic::info(format!("feature alert: {message}")));
self.report.mark_skipped(
FeatureContributionKind::Alert,
"alert",
"transient alert host is not connected during feature installation",
);
}
}
/// Diagnostic sink available to feature installers.
pub struct FeatureDiagnosticSink<'a> {
report: &'a mut FeatureInstallReport,
@@ -1042,15 +1024,74 @@ impl HookContributionRegistrar<'_> {
))
}
fn record(&mut self, declaration: HookDeclaration) {
if !self.report.installed_hooks.contains(&declaration) {
self.report.installed_hooks.push(declaration);
}
}
pub fn add_prompt_submit(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<OnPromptSubmit> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PromptSubmit);
self.require_declared(&declaration)?;
self.hook_builder
.add_named_on_prompt_submit(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
pub fn add_pre_llm_request(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<PreLlmRequest> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreLlmRequest);
self.require_declared(&declaration)?;
self.hook_builder
.add_named_pre_llm_request(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
pub fn add_pre_request(
&mut self,
name: impl Into<String>,
hook: impl Hook<PreLlmRequest> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreRequest);
self.add_pre_llm_request(name, HookExecutionPolicy::fail_closed(), hook)
}
pub fn add_pre_tool_call_with_policy(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<PreToolCall> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
self.require_declared(&declaration)?;
self.hook_builder.add_pre_llm_request(hook);
self.report.installed_hooks.push(declaration);
self.hook_builder
.add_named_pre_tool_call(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
@@ -1059,10 +1100,25 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>,
hook: impl Hook<PreToolCall> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall);
self.add_pre_tool_call_with_policy(name, HookExecutionPolicy::fail_closed(), hook)
}
pub fn add_post_tool_call(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<PostToolCall> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::PostToolCall);
self.require_declared(&declaration)?;
self.hook_builder.add_pre_tool_call(hook);
self.report.installed_hooks.push(declaration);
self.hook_builder
.add_named_post_tool_call(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
@@ -1071,10 +1127,25 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>,
hook: impl Hook<PostToolCall> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::ToolResult);
self.add_post_tool_call(name, HookExecutionPolicy::fail_closed(), hook)
}
pub fn add_assistant_turn_end(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<OnTurnEnd> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::AssistantTurnEnd);
self.require_declared(&declaration)?;
self.hook_builder.add_post_tool_call(hook);
self.report.installed_hooks.push(declaration);
self.hook_builder
.add_named_on_turn_end(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
@@ -1083,10 +1154,82 @@ impl HookContributionRegistrar<'_> {
name: impl Into<String>,
hook: impl Hook<OnTurnEnd> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::TurnEnd);
self.add_assistant_turn_end(name, HookExecutionPolicy::fail_closed(), hook)
}
pub fn add_run_exit(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<RunExit> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunExit);
self.require_declared(&declaration)?;
self.hook_builder.add_on_turn_end(hook);
self.report.installed_hooks.push(declaration);
self.hook_builder
.add_named_run_exit(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
pub fn add_run_committed(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<RunCommitted> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::RunCommitted);
self.require_declared(&declaration)?;
self.hook_builder
.add_named_run_committed(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
pub fn add_before_session_rewrite(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<BeforeSessionRewrite> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::BeforeSessionRewrite);
self.require_declared(&declaration)?;
self.hook_builder
.add_named_before_session_rewrite(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
pub fn add_worker_stopping(
&mut self,
name: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<WorkerStopping> + 'static,
) -> Result<(), FeatureInstallError> {
let declaration = HookDeclaration::new(name, FeatureHookPoint::WorkerStopping);
self.require_declared(&declaration)?;
self.hook_builder
.add_named_worker_stopping(
format!("{}:{}", self.feature_id, declaration.name),
policy,
hook,
)
.map_err(|error| FeatureInstallError::InvalidDescriptor(error.to_string()))?;
self.record(declaration);
Ok(())
}
}
@@ -1124,33 +1267,40 @@ impl FeatureInstructionRegistrar<'_> {
}
}
/// Background task registrar for descriptor/report-only contributions.
/// Registrar for executable, Worker-managed background task contributions.
pub struct BackgroundTaskRegistrar<'a> {
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
registry: &'a mut FeatureBackgroundTaskRegistryBuilder,
report: &'a mut FeatureInstallReport,
}
impl BackgroundTaskRegistrar<'_> {
pub fn declare(
pub fn register(
&mut self,
declaration: BackgroundTaskDeclaration,
spec: BackgroundTaskSpec,
task: impl FeatureBackgroundTask + 'static,
) -> Result<(), FeatureInstallError> {
if !self.declarations.contains_background_task(&declaration) {
if !self
.declarations
.contains_background_task(&spec.declaration)
{
return Err(reject_undeclared_contribution(
self.feature_id,
self.report,
FeatureContributionKind::BackgroundTask,
declaration.name,
spec.declaration.name,
));
}
self.registry
.register(self.feature_id.clone(), spec.clone(), task)?;
if !self
.report
.declared_background_tasks
.iter()
.any(|task| task.name == declaration.name)
.any(|task| task.name == spec.declaration.name)
{
self.report.declared_background_tasks.push(declaration);
self.report.declared_background_tasks.push(spec.declaration);
}
Ok(())
}
@@ -1330,15 +1480,17 @@ impl ProtocolProviderRegistrar<'_> {
}
}
for task in background_tasks {
if !self
.report
.declared_background_tasks
.iter()
.any(|declared| declared.name == task.name)
{
self.report.declared_background_tasks.push(task);
}
if let Some(task) = background_tasks.first() {
let reason = format!(
"protocol provider background task `{}` has no executable Worker-managed handler",
task.name
);
self.report.mark_skipped(
FeatureContributionKind::BackgroundTask,
task.name.clone(),
reason.clone(),
);
return Err(FeatureInstallError::InvalidDescriptor(reason));
}
Ok(())
@@ -1352,6 +1504,7 @@ pub struct FeatureInstallContext<'a> {
pending_tools: &'a mut Vec<ToolDefinition>,
installed_tool_names: &'a mut HashMap<String, FeatureId>,
hook_builder: &'a mut HookRegistryBuilder,
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
service_registry: &'a mut FeatureServiceRegistry,
report: &'a mut FeatureInstallReport,
}
@@ -1392,6 +1545,7 @@ impl FeatureInstallContext<'_> {
BackgroundTaskRegistrar {
feature_id: self.feature_id,
declarations: self.declarations,
registry: self.background_task_builder,
report: self.report,
}
}
@@ -1416,18 +1570,6 @@ impl FeatureInstallContext<'_> {
}
}
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
FeatureNotificationSink {
report: self.report,
}
}
pub fn alerts(&mut self) -> FeatureAlertSink<'_> {
FeatureAlertSink {
report: self.report,
}
}
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
FeatureDiagnosticSink {
report: self.report,
@@ -1440,6 +1582,7 @@ impl FeatureInstallContext<'_> {
pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
pub background_tasks: FeatureBackgroundTaskRegistry,
pub plan_error: Option<FeaturePlanError>,
}
@@ -1795,7 +1938,7 @@ impl FeatureRegistryBuilder {
}
/// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient, A>(
pub(crate) fn install_into_engine<C: LlmClient, A: Send + Sync>(
self,
worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder,
@@ -1861,12 +2004,16 @@ impl FeatureRegistryBuilder {
return FeatureRegistryInstallReport {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let install_hook_checkpoint = hook_builder.checkpoint();
let install_tool_checkpoint = pending_tools.len();
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
let ordered_modules = plan
.ordered_indices
@@ -1884,6 +2031,11 @@ impl FeatureRegistryBuilder {
for (module, descriptor) in ordered_modules {
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor);
let hook_checkpoint = hook_builder.checkpoint();
let background_checkpoint = background_task_builder.checkpoint();
let service_checkpoint = service_registry.clone();
let tool_checkpoint = pending_tools.len();
let installed_tool_checkpoint = installed_tool_names.clone();
let mut required_service_failed = false;
for requirement in descriptor.requires_services.iter().cloned() {
@@ -1920,10 +2072,6 @@ impl FeatureRegistryBuilder {
continue;
}
for background_task in descriptor.background_tasks.iter().cloned() {
report.declared_background_tasks.push(background_task);
}
let install_result = {
let mut context = FeatureInstallContext {
feature_id: &descriptor.id,
@@ -1931,6 +2079,7 @@ impl FeatureRegistryBuilder {
pending_tools,
installed_tool_names: &mut installed_tool_names,
hook_builder,
background_task_builder: &mut background_task_builder,
service_registry: &mut service_registry,
report: &mut report,
};
@@ -1940,18 +2089,81 @@ impl FeatureRegistryBuilder {
match install_result {
Ok(()) => report.installed = true,
Err(error) => {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
report
.diagnostics
.push(FeatureDiagnostic::error(error.to_string()));
}
}
if report.installed {
for hook in &descriptor.hooks {
if !report.installed_hooks.contains(hook) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"feature `{}` declared hook `{}` at {:?} but did not register it",
descriptor.id, hook.name, hook.point
)));
}
}
for task in &descriptor.background_tasks {
if !report.declared_background_tasks.contains(task) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"feature `{}` declared background task `{}` but did not register an executable handler",
descriptor.id, task.name
)));
}
}
if report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
{
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
report.clear_installed_contributions();
}
}
reports.push(report);
}
FeatureRegistryInstallReport {
reports,
services: service_registry,
plan_error: None,
let failed = reports.iter().any(|report| {
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
});
if failed {
hook_builder.rollback_to(install_hook_checkpoint);
pending_tools.truncate(install_tool_checkpoint);
for report in &mut reports {
if report.installed {
report.clear_installed_contributions();
report.diagnostics.push(FeatureDiagnostic::warning(
"feature scope rolled back because another contribution failed",
));
}
}
FeatureRegistryInstallReport {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
plan_error: None,
}
} else {
FeatureRegistryInstallReport {
reports,
services: service_registry,
background_tasks: background_task_builder.build(),
plan_error: None,
}
}
}
}
@@ -1996,9 +2208,11 @@ pub enum FeatureInstallError {
Install(String),
}
pub mod background;
pub mod builtin;
pub mod mcp;
pub mod plugin;
pub(crate) mod session;
#[cfg(test)]
mod tests {
@@ -2398,13 +2612,9 @@ mod tests {
}
#[test]
fn descriptor_contributions_are_recorded() {
fn executable_contributions_are_recorded() {
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"))
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
"daily",
"descriptor-only background task",
));
.with_tool(ToolDeclaration::new("Dummy", "dummy tool"));
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
@@ -2420,7 +2630,7 @@ mod tests {
let feature_report = &report.reports[0];
assert!(feature_report.installed);
assert_eq!(feature_report.installed_tools, vec!["Dummy"]);
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
assert!(feature_report.declared_background_tasks.is_empty());
}
#[test]
@@ -2480,8 +2690,9 @@ mod tests {
})
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert_eq!(pending_tools.len(), 1);
assert!(report.reports[0].installed);
assert!(pending_tools.is_empty());
assert!(!report.reports[0].installed);
assert!(report.reports[0].installed_tools.is_empty());
assert!(!report.reports[1].installed);
assert!(
report.reports[1]
@@ -2558,7 +2769,7 @@ mod tests {
"1.0.0",
"startup-discovered service",
))
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
.with_background_task(BackgroundTaskDeclaration::worker_managed(
"provider-poller",
"provider lifecycle poller",
))
@@ -2568,7 +2779,7 @@ mod tests {
}
#[test]
fn protocol_provider_registers_startup_discovered_contributions_through_worker_path() {
fn protocol_provider_report_only_background_task_is_rejected_atomically() {
let provider = ProtocolProviderDeclaration::new(
ProviderId::builtin("dynamic-provider"),
"test-protocol",
@@ -2599,30 +2810,18 @@ mod tests {
.collect();
let feature_report = &report.reports[0];
assert!(feature_report.installed);
assert_eq!(feature_report.installed_tools, vec!["DynamicTool"]);
assert_eq!(tool_names, vec!["DynamicTool"]);
assert!(!feature_report.installed);
assert!(feature_report.installed_tools.is_empty());
assert!(tool_names.is_empty());
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(feature_report.provided_services.len(), 1);
assert_eq!(
feature_report.provided_services[0].id,
ServiceId::builtin("dynamic-service")
);
assert_eq!(
feature_report.declared_background_tasks[0].name,
"provider-poller"
);
assert!(feature_report.provided_services.is_empty());
assert!(feature_report.declared_background_tasks.is_empty());
assert_eq!(feature_report.protocol_providers.len(), 1);
assert_eq!(
feature_report.protocol_providers[0].state,
ProtocolProviderLifecycleState::Ready
);
assert!(
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("startup discovery completed"))
);
assert!(feature_report.diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.contains("has no executable Worker-managed handler")
}));
}
#[test]
@@ -2779,8 +2978,8 @@ mod tests {
async fn call(
&self,
_input: &crate::hook::ToolCallSummary,
) -> crate::hook::HookPreToolAction {
crate::hook::HookPreToolAction::Continue
) -> Result<crate::hook::HookPreToolAction, crate::hook::HookError> {
Ok(crate::hook::HookPreToolAction::Continue)
}
}
@@ -2804,6 +3003,19 @@ mod tests {
}
}
struct NoopBackgroundTask;
#[async_trait]
impl FeatureBackgroundTask for NoopBackgroundTask {
async fn run(
&self,
_context: background::BackgroundTaskContext,
_cancellation: background::BackgroundTaskCancellation,
) -> Result<(), crate::hook::HookError> {
Ok(())
}
}
struct BackgroundFeature {
descriptor: FeatureDescriptor,
task_name: &'static str,
@@ -2818,12 +3030,22 @@ mod tests {
&self,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
context
.background_tasks()
.declare(BackgroundTaskDeclaration::descriptor_only(
self.task_name,
"runtime background task",
))
let declaration = self
.descriptor
.background_tasks
.iter()
.find(|task| task.name == self.task_name)
.cloned()
.unwrap_or_else(|| {
BackgroundTaskDeclaration::worker_managed(
self.task_name,
"undeclared background task",
)
});
context.background_tasks().register(
BackgroundTaskSpec::single_flight(declaration, std::time::Duration::from_secs(1)),
NoopBackgroundTask,
)
}
}
@@ -2985,25 +3207,44 @@ mod tests {
);
}
#[test]
fn background_task_declaration_is_descriptor_contribution() {
#[tokio::test]
async fn executable_background_task_is_registered_in_worker_scope() {
let descriptor = FeatureDescriptor::builtin("background", "Background")
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
.with_background_task(BackgroundTaskDeclaration::worker_managed(
"declared-task",
"descriptor contribution",
));
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ServiceFeature { descriptor })
.with_module(BackgroundFeature {
descriptor,
task_name: "declared-task",
})
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.reports[0].installed);
assert_eq!(
report.reports[0].declared_background_tasks[0].name,
"declared-task"
);
assert!(report.reports[0].skipped.is_empty());
assert!(matches!(
report
.background_tasks
.start(
&FeatureId::builtin("background"),
"declared-task",
crate::hook::HookInvocationContext::default(),
)
.unwrap(),
background::BackgroundTaskStart::Started { .. }
));
report.background_tasks.shutdown().await.unwrap();
assert!(matches!(
report.background_tasks.diagnostics()[0].outcome,
background::BackgroundTaskOutcome::Completed
| background::BackgroundTaskOutcome::Cancelled
));
}
#[test]
@@ -3118,7 +3359,10 @@ mod tests {
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
assert_eq!(
hook_points,
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
vec![
FeatureHookPoint::PreLlmRequest,
FeatureHookPoint::PreToolCall
]
);
assert!(descriptor.background_tasks.is_empty());
assert!(descriptor.provides_services.is_empty());
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -8,7 +8,8 @@ pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
pub mod memory;
pub mod memory_extract;
pub(crate) mod memory_lifecycle;
pub mod memory_staging_output;
pub mod merge_request;
pub mod objective;
pub mod orchestration;
@@ -19,8 +20,6 @@ pub mod ticket;
pub mod worker_observation;
pub mod workspace_worker_discovery;
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
pub use task::{TaskFeature, task_tools_feature};
pub use ticket::{
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
+308
View File
@@ -18,6 +18,10 @@ use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde_json::json;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
@@ -338,6 +342,151 @@ fn query_schema() -> serde_json::Value {
})
}
pub struct MemoryFeatureInstallPlan {
pub module: MemoryToolsFeature,
pub resident_summary: Option<String>,
pub system_prompt_override: Option<String>,
pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig,
}
impl MemoryFeatureInstallPlan {
pub async fn prepare(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
) -> std::io::Result<Option<Self>> {
Self::prepare_resolved(
manifest.feature.memory.clone(),
client,
prompts,
manifest.profile.clone(),
)
.await
}
async fn prepare_resolved(
config: manifest::ResolvedMemoryFeatureConfig,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
profile: Option<manifest::ProfileManifestSnapshot>,
) -> std::io::Result<Option<Self>> {
let memory_consolidation_worker = profile.as_ref().is_some_and(|snapshot| {
matches!(
&snapshot.source,
manifest::ProfileSource::Registry {
source: manifest::ProfileRegistrySource::Builtin,
name,
..
} if name == "memory-consolidation"
)
});
config
.validate_execution()
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
if !config.profile.enabled {
return Ok(None);
}
let workspace_id = client.workspace_id().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Memory tools require Backend Workspace API authority",
)
})?;
let settings = config
.workspace_settings()
.expect("validated enabled Memory config has Workspace settings");
if settings.workspace_id != workspace_id {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Memory settings belong to {} instead of {}",
settings.workspace_id, workspace_id
),
));
}
let resident_summary = if config.profile.resident.inject_summary {
match client
.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::ResidentSummary(
memory::backend::MemoryResidentSummaryOperation::default(),
),
)
.await
{
Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => {
output.content
}
Ok(other) => {
tracing::debug!(?other, "unexpected resident Memory Backend result");
None
}
Err(error) => {
tracing::debug!(%error, "resident Memory summary unavailable");
None
}
}
} else {
None
};
let system_prompt_override = if memory_consolidation_worker {
let language = settings.language;
Some(
prompts
.memory_consolidation_system(&language)
.map_err(|error| std::io::Error::other(error.to_string()))?,
)
} else {
None
};
Ok(Some(Self {
module: MemoryToolsFeature::new(client, config.profile.staging_tools),
resident_summary,
system_prompt_override,
resolved_config: config,
}))
}
}
#[derive(Clone)]
pub struct MemoryToolsFeature {
tools: Vec<ToolDefinition>,
}
impl MemoryToolsFeature {
pub fn new(client: Arc<dyn WorkspaceClient>, staging_tools: bool) -> Self {
let tools = if staging_tools {
workspace_http_memory_consolidation_tools(client)
} else {
workspace_http_memory_tools(client)
};
Self { tools }
}
}
impl FeatureModule for MemoryToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor = FeatureDescriptor::builtin("memory", "Memory")
.with_description("Workspace Memory document, query, and staging tools.");
for tool in &self.tools {
let (meta, _) = tool();
descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
for tool in &self.tools {
let (meta, _) = tool();
context
.tools()
.register(ToolContribution::new(meta.name, tool.clone()))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -350,6 +499,39 @@ mod tests {
))
}
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let content = content.to_string();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request).unwrap();
let body = serde_json::json!({
"status": "ok",
"result": {
"kind": "tool_output",
"summary": "resident Memory summary collected",
"content": content,
}
})
.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{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
format!("http://{addr}"),
))
}
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions
.into_iter()
@@ -368,6 +550,132 @@ mod tests {
.input_schema
}
#[tokio::test]
async fn memory_install_plan_is_the_fail_closed_config_boundary() {
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
let disabled = MemoryFeatureInstallPlan::prepare_resolved(
manifest::ResolvedMemoryFeatureConfig::default(),
test_client(),
prompts.clone(),
None,
)
.await
.unwrap();
assert!(disabled.is_none());
let mut enabled = manifest::ResolvedMemoryFeatureConfig::default();
enabled.profile.enabled = true;
enabled.profile.resident.inject_summary = false;
assert!(
MemoryFeatureInstallPlan::prepare_resolved(
enabled.clone(),
test_client(),
prompts.clone(),
None,
)
.await
.is_err()
);
enabled
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
let mut foreign = enabled.clone();
foreign.workspace_settings.as_mut().unwrap().workspace_id = "other-workspace".to_string();
assert!(
MemoryFeatureInstallPlan::prepare_resolved(
foreign,
test_client(),
prompts.clone(),
None,
)
.await
.is_err()
);
let plan = MemoryFeatureInstallPlan::prepare_resolved(
enabled.clone(),
test_client(),
prompts.clone(),
None,
)
.await
.unwrap()
.unwrap();
assert!(plan.resident_summary.is_none());
assert!(plan.system_prompt_override.is_none());
enabled.profile.resident.inject_summary = true;
let plan = MemoryFeatureInstallPlan::prepare_resolved(
enabled,
resident_client("# Durable Memory"),
prompts,
None,
)
.await
.unwrap()
.unwrap();
assert_eq!(plan.resident_summary.as_deref(), Some("# Durable Memory"));
}
#[tokio::test]
async fn memory_prompt_contribution_rereads_resident_summary_for_each_install() {
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
let mut config = manifest::ResolvedMemoryFeatureConfig::default();
config.profile.enabled = true;
config
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
let first = MemoryFeatureInstallPlan::prepare_resolved(
config.clone(),
resident_client("first resident summary"),
prompts.clone(),
None,
)
.await
.unwrap()
.unwrap();
let restored = MemoryFeatureInstallPlan::prepare_resolved(
config,
resident_client("updated resident summary"),
prompts,
None,
)
.await
.unwrap()
.unwrap();
assert_eq!(
first.resident_summary.as_deref(),
Some("first resident summary")
);
assert_eq!(
restored.resident_summary.as_deref(),
Some("updated resident summary")
);
}
#[test]
fn memory_feature_owns_normal_and_staging_tool_surfaces() {
let normal = MemoryToolsFeature::new(test_client(), false);
let normal_names = tool_names(normal.tools);
assert!(normal_names.contains(&"MemoryQuery".to_string()));
assert!(!normal_names.contains(&"MemoryStagingList".to_string()));
let staging = MemoryToolsFeature::new(test_client(), true);
assert_eq!(staging.descriptor().id.as_str(), "builtin:memory");
let staging_names = tool_names(staging.tools);
assert!(staging_names.contains(&"MemoryQuery".to_string()));
assert!(staging_names.contains(&"MemoryStagingList".to_string()));
}
#[test]
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
let names = tool_names(workspace_http_memory_tools(test_client()));
File diff suppressed because it is too large Load Diff
@@ -28,7 +28,7 @@ const FINISH_DESCRIPTION: &str =
"Finish Memory extraction after validating the number of candidates staged during this run.";
#[derive(Clone)]
pub(crate) struct MemoryExtractState {
pub(crate) struct MemoryStagingOutputState {
view: Arc<SessionCapture>,
workspace_client: Arc<dyn WorkspaceClient>,
source: SourceRef,
@@ -37,7 +37,7 @@ pub(crate) struct MemoryExtractState {
finished: Arc<Mutex<Option<FinishMemoryExtractionParams>>>,
}
impl MemoryExtractState {
impl MemoryStagingOutputState {
pub(crate) fn new(
view: SessionCapture,
workspace_client: Arc<dyn WorkspaceClient>,
@@ -70,22 +70,20 @@ impl MemoryExtractState {
}
#[derive(Clone)]
pub(crate) struct MemoryExtractFeature {
state: MemoryExtractState,
pub(crate) struct MemoryStagingOutputFeature {
state: MemoryStagingOutputState,
}
impl MemoryExtractFeature {
pub(crate) fn new(state: MemoryExtractState) -> Self {
impl MemoryStagingOutputFeature {
pub(crate) fn new(state: MemoryStagingOutputState) -> Self {
Self { state }
}
}
impl FeatureModule for MemoryExtractFeature {
impl FeatureModule for MemoryStagingOutputFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-extract", "Memory Extract")
.with_description(
"Memory staging and extraction completion, independent from session exploration.",
)
FeatureDescriptor::builtin("memory-staging-output", "Memory Staging Output")
.with_description("Restricted Memory staging output for an extraction Internal Worker.")
.with_tool(ToolDeclaration::new(
"StageMemoryCandidate",
STAGE_DESCRIPTION,
@@ -109,7 +107,7 @@ impl FeatureModule for MemoryExtractFeature {
}
}
fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
fn stage_definition(state: MemoryStagingOutputState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(StageMemoryCandidateParams))
.unwrap_or_else(|_| serde_json::json!({}));
@@ -123,7 +121,7 @@ fn stage_definition(state: MemoryExtractState) -> ToolDefinition {
})
}
fn finish_definition(state: MemoryExtractState) -> ToolDefinition {
fn finish_definition(state: MemoryStagingOutputState) -> ToolDefinition {
Arc::new(move || {
let schema = serde_json::to_value(schemars::schema_for!(FinishMemoryExtractionParams))
.unwrap_or_else(|_| serde_json::json!({}));
@@ -157,7 +155,7 @@ struct FinishMemoryExtractionParams {
}
struct StageMemoryCandidateTool {
state: MemoryExtractState,
state: MemoryStagingOutputState,
}
#[async_trait]
@@ -252,7 +250,7 @@ impl Tool for StageMemoryCandidateTool {
}
struct FinishMemoryExtractionTool {
state: MemoryExtractState,
state: MemoryStagingOutputState,
}
#[async_trait]
@@ -431,8 +429,8 @@ mod tests {
use super::*;
fn state() -> MemoryExtractState {
MemoryExtractState::new(
fn state() -> MemoryStagingOutputState {
MemoryStagingOutputState::new(
SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]),
crate::worker::marker_workspace_client(None, "test-backend"),
SourceRef {
@@ -445,8 +443,8 @@ mod tests {
#[test]
fn memory_extract_declares_only_memory_mutation_tools() {
let descriptor = MemoryExtractFeature::new(state()).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:memory-extract");
let descriptor = MemoryStagingOutputFeature::new(state()).descriptor();
assert_eq!(descriptor.id.as_str(), "builtin:memory-staging-output");
assert_eq!(
descriptor
.tools
@@ -124,7 +124,6 @@ struct ModelBlocker {
ticket: String,
kind: String,
state: Option<String>,
resolved: bool,
}
#[derive(Debug, Serialize)]
@@ -436,7 +435,6 @@ fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
})
}
@@ -760,6 +758,70 @@ mod tests {
assert!(!objective_json.contains("00001TICKETINTERNAL"));
}
#[test]
fn ticket_detail_projection_accepts_current_blocker_shape() {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"title": "Queued Submit",
"body": "Body",
"state": "planning",
"readiness": null,
"priority": "P2",
"created_at": "2026-09-03T00:00:00Z",
"updated_at": "2026-09-03T00:00:00Z",
"events": [],
"relations": {
"outgoing": [],
"incoming": [],
"blockers": [{
"blocking_ticket": "internal-blocker",
"blocking_resource_key": "T-584",
"reason_kind": "depends_on",
"relation_kind": "depends_on",
"note": "required foundation",
"blocking_state": "planning"
}],
"notices": []
},
"linked_objectives": [],
"implementation_reports": [],
"assignments": [],
"current_coder": null,
"merge_request": null,
"evidence": {
"has_merge_request": false,
"has_current_subject_ref": false,
"has_review_request": false,
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
},
"action_eligibility": {
"can_assign_orchestrator": true,
"can_unassign_orchestrator": false,
"can_queue": false,
"can_start_manual_coder": false
},
"event_page": {"next_cursor": null, "has_more": false}
}))
.expect("current Ticket blocker shape must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
assert_eq!(
projected["relations"]["blockers"],
json!([{
"ticket": "T-584",
"kind": "depends_on",
"state": "planning"
}])
);
assert!(!projected.to_string().contains("internal-blocker"));
}
#[test]
fn relation_projection_accepts_current_workspace_api_shapes() {
let outgoing = project_relation(
+13 -7
View File
@@ -125,7 +125,7 @@ impl FeatureModule for TaskFeature {
))
.with_hook(HookDeclaration::new(
"task-reminder-pre-request",
FeatureHookPoint::PreRequest,
FeatureHookPoint::PreLlmRequest,
))
.with_hook(HookDeclaration::new(
"task-reminder-tool-usage",
@@ -209,24 +209,27 @@ struct TaskReminderPreRequestHook {
#[async_trait]
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
async fn call(
&self,
input: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
let tasks = self.state.task_store.list();
if tasks.is_empty() {
return HookPreRequestAction::Continue;
return Ok(HookPreRequestAction::Continue);
}
let (since_task_management, since_reminder) = self.state.reminder_state.note_request();
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
{
return HookPreRequestAction::Continue;
return Ok(HookPreRequestAction::Continue);
}
if let Some(system_items) = input.system_items() {
self.state.reminder_state.note_reminder();
system_items.append_task_reminder(render_task_reminder_body(&tasks));
}
HookPreRequestAction::Continue
Ok(HookPreRequestAction::Continue)
}
}
@@ -236,11 +239,14 @@ struct TaskReminderToolUsageHook {
#[async_trait]
impl Hook<PreToolCall> for TaskReminderToolUsageHook {
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
async fn call(
&self,
input: &ToolCallSummary,
) -> Result<HookPreToolAction, crate::hook::HookError> {
if is_task_management_tool(&input.tool_name) {
self.state.reminder_state.note_task_management();
}
HookPreToolAction::Continue
Ok(HookPreToolAction::Continue)
}
}
+1 -2
View File
@@ -2115,8 +2115,7 @@ read exit_notification || true
meta.name
})
.collect();
assert!(!names.iter().any(|name| name == "Mcp_demo_search_files"));
assert!(names.iter().any(|name| name == "Mcp_demo_unique"));
assert!(names.is_empty());
}
fn shell_tool_server(response: &str) -> McpStdioServerSpec {
+1 -1
View File
@@ -7621,7 +7621,7 @@ mod tests {
)))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(pending.len(), 1);
assert!(pending.is_empty());
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
+119
View File
@@ -0,0 +1,119 @@
use std::sync::Arc;
use agen::{HistoryEntry, UsageRecord};
use serde_json::Value;
use crate::session_history::SessionHistoryMetadata;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommittedRunExit {
Finished,
NonFinal,
Interrupted,
}
/// Immutable projection of one durably committed session-log location.
///
/// Feature code receives this value only after the host has committed the
/// terminal run record. The projection deliberately carries annotated history
/// rather than the public flattened transcript so provenance-sensitive
/// features can construct their own bounded views.
#[derive(Clone)]
pub(crate) struct CommittedSessionCapture {
pub(crate) session_id: String,
pub(crate) segment_id: String,
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
pub(crate) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
pub(crate) usage_history: Vec<UsageRecord>,
pub(crate) extensions: Vec<(String, Value)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CommittedSessionLocation {
pub(crate) session_id: String,
pub(crate) segment_id: String,
/// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
}
impl CommittedSessionCapture {
pub(crate) fn location(&self) -> CommittedSessionLocation {
CommittedSessionLocation {
session_id: self.session_id.clone(),
segment_id: self.segment_id.clone(),
session_revision: self.session_revision,
entry_count: self.entry_count,
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum FeatureSessionError {
#[error("read committed session failed: {0}")]
Capture(String),
#[error("append session extension failed: {0}")]
Extension(String),
}
#[derive(Clone)]
pub(crate) struct CommittedSessionCaptureHandle {
capture: Arc<
dyn Fn() -> Result<CommittedSessionCapture, FeatureSessionError> + Send + Sync + 'static,
>,
}
impl CommittedSessionCaptureHandle {
pub(crate) fn new(
capture: impl Fn() -> Result<CommittedSessionCapture, FeatureSessionError>
+ Send
+ Sync
+ 'static,
) -> Self {
Self {
capture: Arc::new(capture),
}
}
pub(crate) fn capture(&self) -> Result<CommittedSessionCapture, FeatureSessionError> {
(self.capture)()
}
}
#[derive(Clone)]
pub(crate) struct SessionExtensionHandle {
append: Arc<
dyn Fn(&CommittedSessionLocation, &str, Value) -> Result<bool, FeatureSessionError>
+ Send
+ Sync
+ 'static,
>,
}
impl SessionExtensionHandle {
pub(crate) fn new(
append: impl Fn(&CommittedSessionLocation, &str, Value) -> Result<bool, FeatureSessionError>
+ Send
+ Sync
+ 'static,
) -> Self {
Self {
append: Arc::new(append),
}
}
/// Appends an extension only while the committed session is still at the
/// exact location captured by the feature. `Ok(false)` is a stale-write
/// fence, not an I/O failure.
pub(crate) fn append_if_current(
&self,
expected: &CommittedSessionLocation,
domain: &str,
payload: Value,
) -> Result<bool, FeatureSessionError> {
(self.append)(expected, domain, payload)
}
}
+645 -52
View File
@@ -23,8 +23,105 @@ use agen::interceptor::{
};
use agen::tool::{ToolOutput, ToolResult};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use session_store::{SystemItem, SystemReminder};
use thiserror::Error;
use crate::SessionEntryRef;
const HOOK_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
/// Failure category exposed by the safe Worker hook boundary.
///
/// Categories are intentionally closed and payload-free so extensions cannot
/// smuggle provider, credential, or history data into diagnostics.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookErrorCategory {
InvalidInput,
Dependency,
Timeout,
Cancelled,
Trap,
ScopeDisposed,
Internal,
}
/// Bounded hook callback failure. Raw tool arguments, output, prompts, and
/// credentials must never be placed in `diagnostic`.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("{category:?}: {diagnostic}")]
pub struct HookError {
pub category: HookErrorCategory,
pub diagnostic: String,
}
impl HookError {
pub fn new(category: HookErrorCategory, diagnostic: impl Into<String>) -> Self {
Self {
category,
diagnostic: bounded_utf8(diagnostic.into(), HOOK_DIAGNOSTIC_MAX_BYTES),
}
}
}
/// Failure behavior declared when a hook is registered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookFailurePolicy {
/// Gate the current operation when the hook cannot decide safely.
FailClosed,
/// Keep the already-authorized operation moving and emit a diagnostic.
FailOpenWithDiagnostic,
/// Keep committed state intact and mark the failure for operator attention.
AttentionRequired,
}
const DEFAULT_HOOK_TIMEOUT_MS: u64 = 30_000;
const MAX_HOOK_TIMEOUT_MS: u64 = 120_000;
/// Host-enforced execution and failure budget for one hook registration.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookExecutionPolicy {
pub failure: HookFailurePolicy,
pub timeout_ms: u64,
}
impl HookExecutionPolicy {
pub const fn new(failure: HookFailurePolicy, timeout_ms: u64) -> Self {
Self {
failure,
timeout_ms,
}
}
pub const fn fail_closed() -> Self {
Self::new(HookFailurePolicy::FailClosed, DEFAULT_HOOK_TIMEOUT_MS)
}
fn validate(self) -> Result<Self, HookError> {
if self.timeout_ms == 0 || self.timeout_ms > MAX_HOOK_TIMEOUT_MS {
return Err(HookError::new(
HookErrorCategory::InvalidInput,
format!("hook timeout_ms must be within 1..={MAX_HOOK_TIMEOUT_MS}"),
));
}
Ok(self)
}
}
fn bounded_utf8(mut value: String, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value;
}
let mut end = max_bytes;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
value
}
/// Hook-facing prompt-submit action.
///
@@ -285,12 +382,6 @@ pub struct TurnEndInfo {
pub final_text_preview: String,
}
/// Information passed to `OnAbort` hooks.
pub struct AbortInfo {
/// Reason supplied by the aborter.
pub reason: String,
}
// =============================================================================
// Hook Event Kinds
// =============================================================================
@@ -315,10 +406,8 @@ pub struct PreLlmRequest;
pub struct PreToolCall;
/// After each tool completes; observational except it may abort the run.
pub struct PostToolCall;
/// When a turn ends with no tool calls; observational except it may pause.
/// After every terminal assistant response is committed; observational except it may pause.
pub struct OnTurnEnd;
/// When execution is interrupted; observational only.
pub struct OnAbort;
impl HookEventKind for OnPromptSubmit {
type Input = PromptSubmitInfo;
@@ -345,11 +434,6 @@ impl HookEventKind for OnTurnEnd {
type Output = HookTurnEndAction;
}
impl HookEventKind for OnAbort {
type Input = AbortInfo;
type Output = ();
}
// =============================================================================
// Hook Trait
// =============================================================================
@@ -362,25 +446,194 @@ impl HookEventKind for OnAbort {
/// short-circuit on the first non-continue action.
#[async_trait]
pub trait Hook<E: HookEventKind>: Send + Sync {
async fn call(&self, input: &E::Input) -> E::Output;
async fn call(&self, input: &E::Input) -> Result<E::Output, HookError>;
}
// =============================================================================
// Hook Registry
// =============================================================================
/// Bounded identity-only view of the committed history visible at a lifecycle
/// boundary. Hook context never carries history payloads; a Feature that needs
/// content must use a separately granted session-exploration service.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HookHistoryRange {
pub first_entry_ref: Option<SessionEntryRef>,
pub last_entry_ref: Option<SessionEntryRef>,
pub entry_count: usize,
}
/// Stable provenance attached to every Worker lifecycle callback.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HookInvocationContext {
pub workspace_id: Option<String>,
pub worker_id: String,
pub session_id: String,
pub session_revision: u64,
pub run_id: Option<String>,
pub turn_index: Option<usize>,
pub call_id: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunCommittedExit {
Finished,
Paused,
Yielded,
Interrupted,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunExitContext {
pub invocation: HookInvocationContext,
pub exit: RunCommittedExit,
pub history_len: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RunCommittedContext {
pub invocation: HookInvocationContext,
pub exit: RunCommittedExit,
pub committed_history: HookHistoryRange,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionRewriteKind {
Rewind,
Compact,
Fork,
Restore,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BeforeSessionRewriteContext {
pub invocation: HookInvocationContext,
pub kind: SessionRewriteKind,
pub current_history: HookHistoryRange,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BeforeSessionRewriteAction {
Continue,
Deny(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerStoppingContext {
pub invocation: HookInvocationContext,
pub reason: String,
}
pub struct RunExit;
pub struct RunCommitted;
pub struct BeforeSessionRewrite;
pub struct WorkerStopping;
impl HookEventKind for RunExit {
type Input = RunExitContext;
type Output = ();
}
impl HookEventKind for RunCommitted {
type Input = RunCommittedContext;
type Output = ();
}
impl HookEventKind for BeforeSessionRewrite {
type Input = BeforeSessionRewriteContext;
type Output = BeforeSessionRewriteAction;
}
impl HookEventKind for WorkerStopping {
type Input = WorkerStoppingContext;
type Output = ();
}
pub(crate) struct RegisteredHook<E: HookEventKind> {
owner: String,
policy: HookExecutionPolicy,
hook: Box<dyn Hook<E>>,
}
impl<E: HookEventKind> RegisteredHook<E> {
pub(crate) async fn call(&self, input: &E::Input) -> Result<E::Output, HookExecutionError> {
let result = tokio::time::timeout(
std::time::Duration::from_millis(self.policy.timeout_ms),
self.hook.call(input),
)
.await
.unwrap_or_else(|_| {
Err(HookError::new(
HookErrorCategory::Timeout,
"hook exceeded its host-enforced execution deadline",
))
});
result.map_err(|source| HookExecutionError {
owner: self.owner.clone(),
policy: self.policy.failure,
source,
})
}
pub(crate) async fn call_optional(
&self,
input: &E::Input,
) -> Result<Option<E::Output>, HookExecutionError> {
match self.call(input).await {
Ok(output) => Ok(Some(output)),
Err(error) if error.policy == HookFailurePolicy::FailOpenWithDiagnostic => {
tracing::warn!(owner = %error.owner, error = %error.source, "inline hook failed open");
Ok(None)
}
Err(error) => Err(error),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("hook `{owner}` failed under {policy:?}: {source}")]
pub struct HookExecutionError {
pub owner: String,
pub policy: HookFailurePolicy,
pub source: HookError,
}
/// Builder for constructing a frozen `HookRegistry`.
///
/// Hooks are added during setup, then `build()` produces an immutable
/// registry that can be shared via `Arc`.
#[derive(Default)]
pub struct HookRegistryBuilder {
on_prompt_submit: Vec<Box<dyn Hook<OnPromptSubmit>>>,
pre_llm_request: Vec<Box<dyn Hook<PreLlmRequest>>>,
pre_tool_call: Vec<Box<dyn Hook<PreToolCall>>>,
post_tool_call: Vec<Box<dyn Hook<PostToolCall>>>,
on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
on_abort: Vec<Box<dyn Hook<OnAbort>>>,
on_prompt_submit: Vec<RegisteredHook<OnPromptSubmit>>,
pre_llm_request: Vec<RegisteredHook<PreLlmRequest>>,
pre_tool_call: Vec<RegisteredHook<PreToolCall>>,
post_tool_call: Vec<RegisteredHook<PostToolCall>>,
on_turn_end: Vec<RegisteredHook<OnTurnEnd>>,
run_exit: Vec<RegisteredHook<RunExit>>,
run_committed: Vec<RegisteredHook<RunCommitted>>,
before_session_rewrite: Vec<RegisteredHook<BeforeSessionRewrite>>,
worker_stopping: Vec<RegisteredHook<WorkerStopping>>,
}
macro_rules! add_hook_methods {
($default:ident, $named:ident, $field:ident, $event:ty) => {
pub fn $default(&mut self, hook: impl Hook<$event> + 'static) {
self.$named("worker.host", HookExecutionPolicy::fail_closed(), hook)
.expect("host hook policy is valid");
}
pub fn $named(
&mut self,
owner: impl Into<String>,
policy: HookExecutionPolicy,
hook: impl Hook<$event> + 'static,
) -> Result<(), HookError> {
let policy = policy.validate()?;
self.$field.push(RegisteredHook {
owner: owner.into(),
policy,
hook: Box::new(hook),
});
Ok(())
}
};
}
impl HookRegistryBuilder {
@@ -388,31 +641,82 @@ impl HookRegistryBuilder {
Self::default()
}
pub fn add_on_prompt_submit(&mut self, hook: impl Hook<OnPromptSubmit> + 'static) {
self.on_prompt_submit.push(Box::new(hook));
add_hook_methods!(
add_on_prompt_submit,
add_named_on_prompt_submit,
on_prompt_submit,
OnPromptSubmit
);
add_hook_methods!(
add_pre_llm_request,
add_named_pre_llm_request,
pre_llm_request,
PreLlmRequest
);
add_hook_methods!(
add_pre_tool_call,
add_named_pre_tool_call,
pre_tool_call,
PreToolCall
);
add_hook_methods!(
add_post_tool_call,
add_named_post_tool_call,
post_tool_call,
PostToolCall
);
add_hook_methods!(
add_on_turn_end,
add_named_on_turn_end,
on_turn_end,
OnTurnEnd
);
add_hook_methods!(add_run_exit, add_named_run_exit, run_exit, RunExit);
add_hook_methods!(
add_run_committed,
add_named_run_committed,
run_committed,
RunCommitted
);
add_hook_methods!(
add_before_session_rewrite,
add_named_before_session_rewrite,
before_session_rewrite,
BeforeSessionRewrite
);
add_hook_methods!(
add_worker_stopping,
add_named_worker_stopping,
worker_stopping,
WorkerStopping
);
pub(crate) fn checkpoint(&self) -> [usize; 9] {
[
self.on_prompt_submit.len(),
self.pre_llm_request.len(),
self.pre_tool_call.len(),
self.post_tool_call.len(),
self.on_turn_end.len(),
self.run_exit.len(),
self.run_committed.len(),
self.before_session_rewrite.len(),
self.worker_stopping.len(),
]
}
pub fn add_pre_llm_request(&mut self, hook: impl Hook<PreLlmRequest> + 'static) {
self.pre_llm_request.push(Box::new(hook));
pub(crate) fn rollback_to(&mut self, checkpoint: [usize; 9]) {
self.on_prompt_submit.truncate(checkpoint[0]);
self.pre_llm_request.truncate(checkpoint[1]);
self.pre_tool_call.truncate(checkpoint[2]);
self.post_tool_call.truncate(checkpoint[3]);
self.on_turn_end.truncate(checkpoint[4]);
self.run_exit.truncate(checkpoint[5]);
self.run_committed.truncate(checkpoint[6]);
self.before_session_rewrite.truncate(checkpoint[7]);
self.worker_stopping.truncate(checkpoint[8]);
}
pub fn add_pre_tool_call(&mut self, hook: impl Hook<PreToolCall> + 'static) {
self.pre_tool_call.push(Box::new(hook));
}
pub fn add_post_tool_call(&mut self, hook: impl Hook<PostToolCall> + 'static) {
self.post_tool_call.push(Box::new(hook));
}
pub fn add_on_turn_end(&mut self, hook: impl Hook<OnTurnEnd> + 'static) {
self.on_turn_end.push(Box::new(hook));
}
pub fn add_on_abort(&mut self, hook: impl Hook<OnAbort> + 'static) {
self.on_abort.push(Box::new(hook));
}
/// Freeze the builder into an immutable registry.
pub fn build(self) -> HookRegistry {
HookRegistry {
on_prompt_submit: self.on_prompt_submit,
@@ -420,19 +724,140 @@ impl HookRegistryBuilder {
pre_tool_call: self.pre_tool_call,
post_tool_call: self.post_tool_call,
on_turn_end: self.on_turn_end,
on_abort: self.on_abort,
run_exit: self.run_exit,
run_committed: self.run_committed,
before_session_rewrite: self.before_session_rewrite,
worker_stopping: self.worker_stopping,
diagnostics: std::sync::Mutex::new(Vec::new()),
}
}
}
/// Frozen registry of hooks. Constructed via [`HookRegistryBuilder::build()`].
pub struct HookRegistry {
pub(crate) on_prompt_submit: Vec<Box<dyn Hook<OnPromptSubmit>>>,
pub(crate) pre_llm_request: Vec<Box<dyn Hook<PreLlmRequest>>>,
pub(crate) pre_tool_call: Vec<Box<dyn Hook<PreToolCall>>>,
pub(crate) post_tool_call: Vec<Box<dyn Hook<PostToolCall>>>,
pub(crate) on_turn_end: Vec<Box<dyn Hook<OnTurnEnd>>>,
pub(crate) on_abort: Vec<Box<dyn Hook<OnAbort>>>,
pub(crate) on_prompt_submit: Vec<RegisteredHook<OnPromptSubmit>>,
pub(crate) pre_llm_request: Vec<RegisteredHook<PreLlmRequest>>,
pub(crate) pre_tool_call: Vec<RegisteredHook<PreToolCall>>,
pub(crate) post_tool_call: Vec<RegisteredHook<PostToolCall>>,
pub(crate) on_turn_end: Vec<RegisteredHook<OnTurnEnd>>,
run_exit: Vec<RegisteredHook<RunExit>>,
run_committed: Vec<RegisteredHook<RunCommitted>>,
before_session_rewrite: Vec<RegisteredHook<BeforeSessionRewrite>>,
worker_stopping: Vec<RegisteredHook<WorkerStopping>>,
diagnostics: std::sync::Mutex<Vec<HookExecutionError>>,
}
impl HookRegistry {
fn record_diagnostic(&self, error: HookExecutionError) {
let mut diagnostics = self.diagnostics.lock().expect("hook diagnostics poisoned");
diagnostics.push(error);
if diagnostics.len() > 128 {
let remove = diagnostics.len() - 128;
diagnostics.drain(..remove);
}
}
pub(crate) fn record_chain_timeout(&self, lifecycle: &str) {
self.record_diagnostic(HookExecutionError {
owner: format!("worker.{lifecycle}"),
policy: HookFailurePolicy::AttentionRequired,
source: HookError::new(
HookErrorCategory::Timeout,
"hook chain exceeded its host-enforced lifecycle deadline",
),
});
}
pub fn diagnostics(&self) -> Vec<HookExecutionError> {
self.diagnostics
.lock()
.expect("hook diagnostics poisoned")
.clone()
}
pub async fn on_run_exit(&self, context: &RunExitContext) -> Result<(), HookExecutionError> {
for registration in &self.run_exit {
if let Err(error) = registration.call(context).await {
self.record_diagnostic(error.clone());
match error.policy {
HookFailurePolicy::FailOpenWithDiagnostic => {
tracing::warn!(owner = %error.owner, error = %error.source, "run-exit hook failed open");
}
HookFailurePolicy::FailClosed | HookFailurePolicy::AttentionRequired => {
return Err(error);
}
}
}
}
Ok(())
}
pub async fn on_run_committed(
&self,
context: &RunCommittedContext,
) -> Result<(), HookExecutionError> {
for registration in &self.run_committed {
if let Err(error) = registration.call(context).await {
self.record_diagnostic(error.clone());
match error.policy {
HookFailurePolicy::FailOpenWithDiagnostic => {
tracing::warn!(owner = %error.owner, error = %error.source, "run-committed hook failed open");
}
HookFailurePolicy::FailClosed | HookFailurePolicy::AttentionRequired => {
return Err(error);
}
}
}
}
Ok(())
}
pub async fn before_session_rewrite(
&self,
context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookExecutionError> {
let mut denials = Vec::new();
for registration in &self.before_session_rewrite {
match registration.call(context).await {
Ok(BeforeSessionRewriteAction::Continue) => {}
Ok(BeforeSessionRewriteAction::Deny(reason)) => {
denials.push((registration.owner.clone(), reason));
}
Err(error) if error.policy == HookFailurePolicy::FailOpenWithDiagnostic => {
self.record_diagnostic(error.clone());
tracing::warn!(owner = %error.owner, error = %error.source, "session-rewrite hook failed open");
}
Err(error) => {
self.record_diagnostic(error.clone());
return Err(error);
}
}
}
denials.sort_by(|left, right| left.0.cmp(&right.0));
Ok(denials
.into_iter()
.next()
.map(|(_, reason)| BeforeSessionRewriteAction::Deny(reason))
.unwrap_or(BeforeSessionRewriteAction::Continue))
}
pub async fn on_worker_stopping(
&self,
context: &WorkerStoppingContext,
) -> Result<(), HookExecutionError> {
for registration in &self.worker_stopping {
if let Err(error) = registration.call(context).await {
self.record_diagnostic(error.clone());
match error.policy {
HookFailurePolicy::FailOpenWithDiagnostic
| HookFailurePolicy::AttentionRequired => {
tracing::warn!(owner = %error.owner, error = %error.source, "worker-stopping hook requires attention");
}
HookFailurePolicy::FailClosed => return Err(error),
}
}
}
Ok(())
}
}
#[cfg(test)]
@@ -494,4 +919,172 @@ mod tests {
let pause_action = HookPreToolAction::Pause.into_worker_action("call_4".into());
assert!(matches!(pause_action, PreToolAction::Pause));
}
struct RewriteHook {
action: BeforeSessionRewriteAction,
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for RewriteHook {
async fn call(
&self,
_input: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
Ok(self.action.clone())
}
}
struct FailingRewriteHook;
#[async_trait]
impl Hook<BeforeSessionRewrite> for FailingRewriteHook {
async fn call(
&self,
_input: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
Err(HookError::new(
HookErrorCategory::Dependency,
"provider unavailable",
))
}
}
fn rewrite_context() -> BeforeSessionRewriteContext {
BeforeSessionRewriteContext {
invocation: HookInvocationContext {
workspace_id: Some("workspace".into()),
worker_id: "worker".into(),
session_id: "session".into(),
session_revision: 4,
run_id: None,
turn_index: None,
call_id: None,
},
kind: SessionRewriteKind::Compact,
current_history: HookHistoryRange::default(),
}
}
#[tokio::test]
async fn rewrite_denials_are_resolved_by_owner_not_registration_order() {
let mut builder = HookRegistryBuilder::new();
builder
.add_named_before_session_rewrite(
"z-feature",
HookExecutionPolicy::fail_closed(),
RewriteHook {
action: BeforeSessionRewriteAction::Deny("z denied".into()),
},
)
.unwrap();
builder
.add_named_before_session_rewrite(
"a-feature",
HookExecutionPolicy::fail_closed(),
RewriteHook {
action: BeforeSessionRewriteAction::Deny("a denied".into()),
},
)
.unwrap();
assert_eq!(
builder
.build()
.before_session_rewrite(&rewrite_context())
.await
.unwrap(),
BeforeSessionRewriteAction::Deny("a denied".into())
);
}
#[tokio::test]
async fn hook_failure_policy_is_applied_at_the_registry_boundary() {
let mut fail_open = HookRegistryBuilder::new();
fail_open
.add_named_before_session_rewrite(
"feature",
HookExecutionPolicy::new(HookFailurePolicy::FailOpenWithDiagnostic, 30_000),
FailingRewriteHook,
)
.unwrap();
let fail_open = fail_open.build();
assert_eq!(
fail_open
.before_session_rewrite(&rewrite_context())
.await
.unwrap(),
BeforeSessionRewriteAction::Continue
);
assert_eq!(fail_open.diagnostics().len(), 1);
let mut fail_closed = HookRegistryBuilder::new();
fail_closed
.add_named_before_session_rewrite(
"feature",
HookExecutionPolicy::fail_closed(),
FailingRewriteHook,
)
.unwrap();
let error = fail_closed
.build()
.before_session_rewrite(&rewrite_context())
.await
.unwrap_err();
assert_eq!(error.source.category, HookErrorCategory::Dependency);
}
struct NeverReturns;
#[async_trait]
impl Hook<BeforeSessionRewrite> for NeverReturns {
async fn call(
&self,
_input: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
std::future::pending().await
}
}
#[tokio::test]
async fn hook_execution_deadline_cancels_a_non_cooperative_callback() {
let mut builder = HookRegistryBuilder::new();
builder
.add_named_before_session_rewrite(
"feature",
HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 10),
NeverReturns,
)
.unwrap();
let registry = builder.build();
let error = tokio::time::timeout(
std::time::Duration::from_millis(100),
registry.before_session_rewrite(&rewrite_context()),
)
.await
.expect("host hook deadline must terminate the callback")
.unwrap_err();
assert_eq!(error.source.category, HookErrorCategory::Timeout);
assert_eq!(registry.diagnostics().len(), 1);
}
#[test]
fn invalid_hook_execution_budget_is_rejected_before_registration() {
let mut builder = HookRegistryBuilder::new();
let error = builder
.add_named_before_session_rewrite(
"feature",
HookExecutionPolicy::new(HookFailurePolicy::FailClosed, 0),
NeverReturns,
)
.unwrap_err();
assert_eq!(error.category, HookErrorCategory::InvalidInput);
}
#[test]
fn hook_diagnostics_are_utf8_bounded() {
let error = HookError::new(HookErrorCategory::Internal, "".repeat(1_000));
assert!(error.diagnostic.len() <= HOOK_DIAGNOSTIC_MAX_BYTES);
assert!(error.diagnostic.is_char_boundary(error.diagnostic.len()));
}
}
+35 -29
View File
@@ -123,14 +123,14 @@ where
// Internal identities are run-scoped and never enter the public Runtime Worker catalog.
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
// Internal jobs only receive features supplied below. A parent manifest must not accidentally
// grant its normal public tool surface or recursively schedule memory work.
// Internal jobs only receive the explicitly supplied Feature set below. A
// parent manifest cannot accidentally grant its normal public tool surface
// or recursively schedule Feature-owned background work.
manifest.feature = Default::default();
manifest.plugins = Default::default();
manifest.mcp = Default::default();
manifest.skills = None;
manifest.compaction = None;
manifest.memory = None;
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
@@ -164,6 +164,7 @@ where
identity: identity.clone(),
history_entries: 0,
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
@@ -210,7 +211,7 @@ where
let segment_id = worker.segment_id();
on_cancel_sender(worker.engine_mut().cancel_sender());
match worker.run_text(&input).await {
let outcome = match worker.run_text(&input).await {
Ok(lifecycle @ WorkerRunResult::Finished)
| Ok(lifecycle @ WorkerRunResult::Paused)
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
@@ -239,7 +240,11 @@ where
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
}
};
worker
.stop_feature_runtime("internal Worker terminal outcome")
.await;
outcome
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -544,7 +549,6 @@ pub(crate) async fn spawn_internal_worker_session(
authority,
} = spec;
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
manifest.memory = None;
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
@@ -575,6 +579,7 @@ pub(crate) async fn spawn_internal_worker_session(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
@@ -645,7 +650,6 @@ pub(crate) fn prepare_internal_worker_from_spec(
manifest.mcp = Default::default();
manifest.skills = None;
manifest.compaction = None;
manifest.memory = None;
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
@@ -669,6 +673,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
@@ -782,7 +787,8 @@ pub(crate) async fn prepare_internal_worker_session(
};
tokio::spawn(async move {
while let Some(command) = command_rx.recv().await {
let mut stop_done = None;
'actor: while let Some(command) = command_rx.recv().await {
match command {
InternalWorkerSessionCommand::Run(input) => {
actor_in_flight.clear();
@@ -825,21 +831,16 @@ pub(crate) async fn prepare_internal_worker_session(
Some(InternalWorkerSessionCommand::Stop(done)) => {
let _ = cancel_sender.send(()).await;
let _ = (&mut run).await;
actor_in_flight.clear();
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
let _ = done.send(());
return;
stop_done = Some(done);
break 'actor;
}
Some(InternalWorkerSessionCommand::Run(_)) => {
// `send` reserves Running atomically, so a second Run cannot be enqueued.
}
None => {
let _ = cancel_sender.send(()).await;
actor_in_flight.clear();
return;
let _ = (&mut run).await;
break 'actor;
}
}
}
@@ -847,22 +848,27 @@ pub(crate) async fn prepare_internal_worker_session(
}
}
InternalWorkerSessionCommand::Stop(done) => {
actor_in_flight.clear();
status.store(
InternalWorkerSessionStatus::Stopped.encode(),
std::sync::atomic::Ordering::Release,
);
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Stopped,
});
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
let _ = done.send(());
return;
stop_done = Some(done);
break;
}
}
}
worker
.stop_feature_runtime("internal Worker session stopped")
.await;
actor_in_flight.clear();
status.store(
InternalWorkerSessionStatus::Stopped.encode(),
std::sync::atomic::Ordering::Release,
);
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Stopped,
});
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
if let Some(done) = stop_done {
let _ = done.send(());
}
});
Ok(handle)
+364 -91
View File
@@ -15,7 +15,9 @@ use std::sync::{Arc, Mutex};
use agen::Item;
use agen::UsageRecord;
use agen::interceptor::{
Interceptor, PostToolAction, PreRequestAction, PreToolAction, PromptAction, ToolCallInfo,
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
InterceptorResult, PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext,
PreRequestAction, PreToolAction, PromptAction, PromptSubmitContext, ToolCallInfo,
ToolResultInfo, TurnEndAction,
};
use agen::tool::ToolOutput;
@@ -28,9 +30,9 @@ use crate::compact::usage_tracker::UsageTracker;
use session_store::SystemItem;
use crate::hook::{
AbortInfo, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
HookEventKind, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookPromptAction,
HookRegistry, HookTurnEndAction, PreRequestContext, PreRequestInfo, PromptSubmitInfo,
SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
RegisteredHook, SystemItemAppendHandle, ToolCallSummary, ToolResultSummary, TurnEndInfo,
};
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
@@ -41,6 +43,32 @@ use agen::token_counter::total_tokens;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
const INLINE_HOOK_CHAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
async fn call_hook_before_deadline<E: HookEventKind>(
hook: &RegisteredHook<E>,
input: &E::Input,
deadline: tokio::time::Instant,
) -> Result<Option<E::Output>, InterceptorError> {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(InterceptorError::new(
InterceptorErrorCategory::Policy,
"Worker hook chain exceeded its host-enforced lifecycle deadline",
));
}
tokio::time::timeout(remaining, hook.call_optional(input))
.await
.map_err(|_| {
InterceptorError::new(
InterceptorErrorCategory::Policy,
"Worker hook chain exceeded its host-enforced lifecycle deadline",
)
})?
.map_err(|error| {
InterceptorError::new(InterceptorErrorCategory::Dependency, error.to_string())
})
}
pub(crate) struct WorkerInterceptor {
registry: Arc<HookRegistry>,
@@ -231,8 +259,12 @@ impl WorkerInterceptor {
}
#[async_trait]
impl Interceptor for WorkerInterceptor {
async fn on_prompt_submit(&self, item: &mut Item) -> PromptAction {
impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
async fn on_prompt_submit(
&self,
context: PromptSubmitContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PromptAction> {
let item = context.item;
let turn_index = self.next_turn_index.fetch_add(1, Ordering::Relaxed);
self.tool_calls_this_turn.store(0, Ordering::Relaxed);
@@ -240,19 +272,27 @@ impl Interceptor for WorkerInterceptor {
input_text: extract_message_text(item).unwrap_or_default(),
turn_index,
};
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
let mut cancellations = Vec::new();
for hook in &self.registry.on_prompt_submit {
let action = hook.call(&info).await;
if !matches!(action, HookPromptAction::Continue) {
return action.into();
let Some(action) = call_hook_before_deadline(hook, &info, deadline).await? else {
continue;
};
if let HookPromptAction::Cancel(reason) = action {
cancellations.push(reason);
}
}
cancellations.sort();
if let Some(reason) = cancellations.into_iter().next() {
return Ok(PromptAction::Cancel(reason));
}
let mut extras: Vec<SystemItem> = std::mem::take(
&mut *self
.pending_attachments
.lock()
.expect("pending_attachments poisoned"),
);
if extras.is_empty() {
Ok(if extras.is_empty() {
PromptAction::Continue
} else {
// Commit the typed system items first, then hand the
@@ -266,10 +306,13 @@ impl Interceptor for WorkerInterceptor {
Ok(()) => PromptAction::ContinueWith(items),
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
}
}
})
}
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
async fn pending_history_appends(
&self,
_context: PendingHistoryAppendsContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<Vec<Item>> {
let drained = self.pending_notifies.drain();
if drained.is_empty() {
return Ok(Vec::new());
@@ -295,7 +338,10 @@ impl Interceptor for WorkerInterceptor {
Ok(system_item) => system_item,
Err(error) => {
self.pending_notifies.requeue_front(drained);
return Err(format!("failed to render notify_wrapper: {error}"));
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("failed to render notify_wrapper: {error}"),
));
}
};
items.push(system_item.to_history_item());
@@ -303,15 +349,22 @@ impl Interceptor for WorkerInterceptor {
}
if let Err(error) = self.commit_system_items(&system_items) {
self.pending_notifies.requeue_front(drained);
return Err(format!("session persistence failed: {error}"));
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("session persistence failed: {error}"),
));
}
Ok(items)
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
async fn pre_llm_request(
&self,
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PreRequestAction> {
let context = context.items;
let initial_tokens = self.estimated_tokens(context);
if self.request_threshold_exceeded(initial_tokens, context) {
return PreRequestAction::Yield;
return Ok(PreRequestAction::Yield);
}
let info = PreRequestInfo {
item_count: context.len(),
@@ -325,12 +378,28 @@ impl Interceptor for WorkerInterceptor {
.as_ref()
.map(|_| SystemItemAppendHandle::new(Arc::clone(&pending_hook_system_items)));
let hook_context = PreRequestContext::new(info, system_item_sink);
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
let mut cancellations = Vec::new();
let mut should_yield = false;
for hook in &self.registry.pre_llm_request {
let action = hook.call(&hook_context).await;
if !matches!(action, HookPreRequestAction::Continue) {
return action.into();
let Some(action) = call_hook_before_deadline(hook, &hook_context, deadline).await?
else {
continue;
};
match action {
HookPreRequestAction::Continue => {}
HookPreRequestAction::Yield => should_yield = true,
HookPreRequestAction::Cancel(reason) => cancellations.push(reason),
}
}
cancellations.sort();
if let Some(reason) = cancellations.into_iter().next() {
return Ok(PreRequestAction::Cancel(reason));
}
if should_yield {
return Ok(PreRequestAction::Yield);
}
let mut system_items: Vec<SystemItem> = std::mem::take(
&mut *pending_hook_system_items
@@ -353,44 +422,73 @@ impl Interceptor for WorkerInterceptor {
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
if let Err(error) = self.commit_system_items(&system_items) {
return PreRequestAction::Cancel(format!("session persistence failed: {error}"));
return Ok(PreRequestAction::Cancel(format!(
"session persistence failed: {error}"
)));
}
return if appended_items.is_empty() {
return Ok(if appended_items.is_empty() {
PreRequestAction::Yield
} else {
PreRequestAction::YieldWith(appended_items)
};
});
}
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
usage_tracker.note_request(effective_context.len());
}
if system_items.is_empty() {
return PreRequestAction::Continue;
return Ok(PreRequestAction::Continue);
}
match self.commit_system_items(&system_items) {
Ok(match self.commit_system_items(&system_items) {
Ok(()) => PreRequestAction::ContinueWith(appended_items),
Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")),
}
})
}
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
async fn pre_tool_call(
&self,
info: &mut ToolCallInfo<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PreToolAction> {
let summary = ToolCallSummary {
call_id: info.call.id.clone(),
tool_name: info.call.name.clone(),
arguments: info.call.input.clone(),
};
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
let mut aborts = Vec::new();
let mut should_pause = false;
let mut denials = Vec::new();
for hook in &self.registry.pre_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, HookPreToolAction::Continue) {
return action.into_worker_action(summary.call_id.clone());
let Some(action) = call_hook_before_deadline(hook, &summary, deadline).await? else {
continue;
};
match action {
HookPreToolAction::Continue => {}
HookPreToolAction::Pause => should_pause = true,
HookPreToolAction::Deny(reason) => denials.push(reason),
HookPreToolAction::Abort(reason) => aborts.push(reason),
}
}
aborts.sort();
if let Some(reason) = aborts.into_iter().next() {
return Ok(HookPreToolAction::Abort(reason).into_worker_action(summary.call_id.clone()));
}
if should_pause {
return Ok(PreToolAction::Pause);
}
denials.sort();
if let Some(reason) = denials.into_iter().next() {
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
}
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
PreToolAction::Continue
Ok(PreToolAction::Continue)
}
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
async fn post_tool_call(
&self,
info: &ToolResultInfo<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PostToolAction> {
let summary = ToolResultSummary {
call_id: info.result.tool_use_id.clone(),
tool_name: info.call.name.clone(),
@@ -402,21 +500,34 @@ impl Interceptor for WorkerInterceptor {
attachments: Vec::new(),
},
};
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
let mut aborts = Vec::new();
for hook in &self.registry.post_tool_call {
let action = hook.call(&summary).await;
if !matches!(action, HookPostToolAction::Continue) {
return action.into();
let Some(action) = call_hook_before_deadline(hook, &summary, deadline).await? else {
continue;
};
if let HookPostToolAction::Abort(reason) = action {
aborts.push(reason);
}
}
PostToolAction::Continue
aborts.sort();
if let Some(reason) = aborts.into_iter().next() {
return Ok(PostToolAction::Abort(reason));
}
Ok(PostToolAction::Continue)
}
async fn on_turn_end(&self, history: &[Item]) -> TurnEndAction {
async fn on_assistant_turn_end(
&self,
context: AssistantTurnEndContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<TurnEndAction> {
let history = context.history;
let final_text_preview = history
.iter()
.rev()
.find(|i| i.is_assistant_message())
.and_then(extract_message_text)
.find(|entry| entry.item.is_assistant_message())
.and_then(|entry| extract_message_text(&entry.item))
.map(|t| preview(&t, FINAL_TEXT_PREVIEW_LIMIT))
.unwrap_or_default();
let info = TurnEndInfo {
@@ -424,22 +535,20 @@ impl Interceptor for WorkerInterceptor {
tool_calls_count: self.tool_calls_this_turn.load(Ordering::Relaxed),
final_text_preview,
};
let deadline = tokio::time::Instant::now() + INLINE_HOOK_CHAIN_TIMEOUT;
let mut should_pause = false;
for hook in &self.registry.on_turn_end {
let action = hook.call(&info).await;
if !matches!(action, HookTurnEndAction::Finish) {
return action.into();
let Some(action) = call_hook_before_deadline(hook, &info, deadline).await? else {
continue;
};
if matches!(action, HookTurnEndAction::Pause) {
should_pause = true;
}
}
TurnEndAction::Finish
}
async fn on_abort(&self, reason: &str) {
let info = AbortInfo {
reason: reason.to_string(),
};
for hook in &self.registry.on_abort {
hook.call(&info).await;
if should_pause {
return Ok(TurnEndAction::Pause);
}
Ok(TurnEndAction::Finish)
}
}
@@ -509,6 +618,7 @@ mod tests {
Hook, HookPostToolAction, HookPreRequestAction, HookPreToolAction, HookRegistryBuilder,
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
};
use crate::session_history::{WorkerHistoryProvenance, history_entry};
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
@@ -518,9 +628,12 @@ mod tests {
#[async_trait]
impl Hook<PreLlmRequest> for CountingHook {
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
async fn call(
&self,
_info: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
self.0.fetch_add(1, Ordering::Relaxed);
HookPreRequestAction::Continue
Ok(HookPreRequestAction::Continue)
}
}
@@ -559,16 +672,22 @@ mod tests {
#[async_trait]
impl Hook<PreLlmRequest> for AppendingPreRequestHook {
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
async fn call(
&self,
input: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
if let Some(system_items) = input.system_items() {
self.saw_handle.store(true, Ordering::Relaxed);
system_items.append_task_reminder("hook reminder");
}
HookPreRequestAction::Continue
Ok(HookPreRequestAction::Continue)
}
}
fn task_tool_call_info(name: &str, input: serde_json::Value) -> ToolCallInfo {
fn task_tool_call_info(
name: &str,
input: serde_json::Value,
) -> ToolCallInfo<'static, SessionHistoryMetadata> {
let def = crate::feature::builtin::task::task_tools(
crate::feature::builtin::task::TaskStore::new(),
)
@@ -580,6 +699,8 @@ mod tests {
.expect("task tool definition");
let (meta, tool) = def();
ToolCallInfo {
invocation: Default::default(),
history: &[],
call: agen::tool::ToolCall {
id: "call-id".into(),
name: name.into(),
@@ -623,7 +744,14 @@ mod tests {
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Yield));
// Hook must not run when an internal mechanism short-circuits first.
@@ -655,7 +783,14 @@ mod tests {
})),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
match action {
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
@@ -692,7 +827,14 @@ mod tests {
)
.with_usage_tracker(usage_tracker);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Yield));
}
@@ -716,7 +858,14 @@ mod tests {
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -757,7 +906,14 @@ mod tests {
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -784,7 +940,14 @@ mod tests {
None,
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -805,7 +968,14 @@ mod tests {
None,
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -834,7 +1004,14 @@ mod tests {
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(saw_handle.load(Ordering::Relaxed));
let PreRequestAction::ContinueWith(items) = action else {
@@ -881,7 +1058,14 @@ mod tests {
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(!saw_handle.load(Ordering::Relaxed));
assert!(matches!(action, PreRequestAction::Continue));
@@ -891,33 +1075,42 @@ mod tests {
#[async_trait]
impl Hook<PreLlmRequest> for AbortingHook {
async fn call(&self, _info: &PreRequestContext) -> HookPreRequestAction {
async fn call(
&self,
_info: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
self.0.store(true, Ordering::Relaxed);
HookPreRequestAction::Cancel("nope".into())
Ok(HookPreRequestAction::Cancel("nope".into()))
}
}
#[tokio::test]
async fn public_pre_tool_hook_deny_becomes_synthetic_error_and_short_circuits() {
async fn public_pre_tool_hook_denials_compose_without_short_circuiting() {
struct DenyToolHook(Arc<AtomicUsize>);
struct CountingToolHook(Arc<AtomicUsize>);
#[async_trait]
impl Hook<PreToolCall> for DenyToolHook {
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
async fn call(
&self,
input: &ToolCallSummary,
) -> Result<HookPreToolAction, crate::hook::HookError> {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.call_id, "call-id");
assert_eq!(input.tool_name, "TaskList");
assert_eq!(input.arguments, serde_json::json!({"scope": "all"}));
HookPreToolAction::Deny("blocked by public hook".into())
Ok(HookPreToolAction::Deny("blocked by public hook".into()))
}
}
#[async_trait]
impl Hook<PreToolCall> for CountingToolHook {
async fn call(&self, _input: &ToolCallSummary) -> HookPreToolAction {
async fn call(
&self,
_input: &ToolCallSummary,
) -> Result<HookPreToolAction, crate::hook::HookError> {
self.0.fetch_add(1, Ordering::Relaxed);
HookPreToolAction::Continue
Ok(HookPreToolAction::Continue)
}
}
@@ -938,7 +1131,7 @@ mod tests {
);
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
let action = interceptor.pre_tool_call(&mut info).await;
let action = interceptor.pre_tool_call(&mut info).await.unwrap();
match action {
PreToolAction::SyntheticResult(result) => {
@@ -950,7 +1143,7 @@ mod tests {
other => panic!("expected synthetic denial, got {other:?}"),
}
assert_eq!(first_count.load(Ordering::Relaxed), 1);
assert_eq!(second_count.load(Ordering::Relaxed), 0);
assert_eq!(second_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
@@ -959,14 +1152,17 @@ mod tests {
#[async_trait]
impl Hook<PostToolCall> for AbortAfterToolHook {
async fn call(&self, input: &ToolResultSummary) -> HookPostToolAction {
async fn call(
&self,
input: &ToolResultSummary,
) -> Result<HookPostToolAction, crate::hook::HookError> {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.call_id, "call-id");
assert_eq!(input.tool_name, "TaskList");
assert!(!input.is_error);
assert_eq!(input.output.summary, "ok");
assert_eq!(input.output.content.as_deref(), Some("full"));
HookPostToolAction::Abort("post tool abort".into())
Ok(HookPostToolAction::Abort("post tool abort".into()))
}
}
@@ -984,7 +1180,9 @@ mod tests {
None,
);
let info = task_tool_call_info("TaskList", serde_json::json!({}));
let mut result_info = ToolResultInfo {
let result_info = ToolResultInfo {
invocation: Default::default(),
history: &[],
call: info.call,
result: agen::tool::ToolResult::from_output(
"call-id",
@@ -1000,7 +1198,7 @@ mod tests {
context: info.context,
};
let action = interceptor.post_tool_call(&mut result_info).await;
let action = interceptor.post_tool_call(&result_info).await.unwrap();
assert_eq!(action, PostToolAction::Abort("post tool abort".to_string()));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -1012,12 +1210,15 @@ mod tests {
#[async_trait]
impl Hook<OnTurnEnd> for PauseTurnEndHook {
async fn call(&self, input: &TurnEndInfo) -> HookTurnEndAction {
async fn call(
&self,
input: &TurnEndInfo,
) -> Result<HookTurnEndAction, crate::hook::HookError> {
self.0.fetch_add(1, Ordering::Relaxed);
assert_eq!(input.turn_index, 0);
assert_eq!(input.tool_calls_count, 0);
assert_eq!(input.final_text_preview, "done");
HookTurnEndAction::Pause
Ok(HookTurnEndAction::Pause)
}
}
@@ -1034,9 +1235,25 @@ mod tests {
test_prompts(),
None,
);
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
let action = interceptor.on_turn_end(&history).await;
let history = vec![
history_entry(
Item::user_message("hi"),
WorkerHistoryProvenance::LegacyUnknown,
),
history_entry(
Item::assistant_message("done"),
WorkerHistoryProvenance::LegacyUnknown,
),
];
let action = interceptor
.on_assistant_turn_end(AssistantTurnEndContext {
invocation: Default::default(),
assistant_entries: &history[1..],
history: &history,
tool_calls: &[],
})
.await
.unwrap();
assert!(matches!(action, TurnEndAction::Pause));
assert_eq!(count.load(Ordering::Relaxed), 1);
@@ -1073,7 +1290,14 @@ mod tests {
let ctx_items = vec![Item::user_message("hi")];
for _ in 0..23 {
let mut ctx = ctx_items.clone();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
usage_tracker.record_usage(&agen::event::UsageEvent {
input_tokens: Some(10),
@@ -1085,7 +1309,14 @@ mod tests {
}
let mut ctx = ctx_items.clone();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
let appended_len = match action {
PreRequestAction::ContinueWith(items) => items.len(),
other => panic!("expected reminder append, got {other:?}"),
@@ -1158,7 +1389,13 @@ mod tests {
));
buffer.push_notify("updated".to_string(), false);
let appends = interceptor.pending_history_appends().await.unwrap();
let appends = interceptor
.pending_history_appends(PendingHistoryAppendsContext {
invocation: Default::default(),
history: &[],
})
.await
.unwrap();
assert_eq!(appends.len(), 1);
assert!(format!("{:?}", appends[0]).contains("CURRENT-PROJECTION updated"));
let committed = committed.lock().unwrap();
@@ -1208,9 +1445,19 @@ mod tests {
));
buffer.push_notify("must persist".to_string(), false);
let error = interceptor.pending_history_appends().await.unwrap_err();
let error = interceptor
.pending_history_appends(PendingHistoryAppendsContext {
invocation: Default::default(),
history: &[],
})
.await
.unwrap_err();
assert!(error.contains("failed to render notify_wrapper"));
assert!(
error
.diagnostic()
.contains("failed to render notify_wrapper")
);
let requeued = buffer.drain();
assert_eq!(requeued.len(), 1);
}
@@ -1232,7 +1479,13 @@ mod tests {
None,
);
let items = interceptor.pending_history_appends().await.unwrap();
let items = interceptor
.pending_history_appends(PendingHistoryAppendsContext {
invocation: Default::default(),
history: &[],
})
.await
.unwrap();
assert_eq!(items.len(), 2);
let first = items[0].as_text().unwrap_or_default();
let second = items[1].as_text().unwrap_or_default();
@@ -1246,7 +1499,13 @@ mod tests {
);
// Empty buffer → empty Vec (no synthesised items).
let again = interceptor.pending_history_appends().await.unwrap();
let again = interceptor
.pending_history_appends(PendingHistoryAppendsContext {
invocation: Default::default(),
history: &[],
})
.await
.unwrap();
assert!(again.is_empty());
}
@@ -1269,7 +1528,14 @@ mod tests {
None,
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Continue));
assert_eq!(ctx.len(), 1, "pre_llm_request must not append notifies");
@@ -1299,10 +1565,17 @@ mod tests {
None,
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::Cancel(_)));
assert!(first_called.load(Ordering::Relaxed));
assert_eq!(second_count.load(Ordering::Relaxed), 0);
assert_eq!(second_count.load(Ordering::Relaxed), 1);
}
}
+1
View File
@@ -50,6 +50,7 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use session_capture::SessionEntryRef;
pub use session_history::{
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
WorkerHistoryProvenance, WorkerSubjectSnapshot,
+8 -5
View File
@@ -45,14 +45,17 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
#[async_trait]
impl Hook<PreToolCall> for PermissionHook {
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
match self.action_for(input) {
async fn call(
&self,
input: &ToolCallSummary,
) -> Result<HookPreToolAction, crate::hook::HookError> {
Ok(match self.action_for(input) {
ToolPermissionAction::Allow => HookPreToolAction::Continue,
ToolPermissionAction::Deny => HookPreToolAction::Deny(permission_denied_message(input)),
ToolPermissionAction::Ask => {
HookPreToolAction::Deny(permission_ask_unsupported_message(input))
}
}
})
}
}
@@ -174,7 +177,7 @@ mod tests {
))
.await;
match denied {
HookPreToolAction::Deny(message) => {
Ok(HookPreToolAction::Deny(message)) => {
assert!(message.contains("permission denied"));
assert!(message.contains("Bash"));
}
@@ -192,7 +195,7 @@ mod tests {
))
.await;
match asked {
HookPreToolAction::Deny(message) => {
Ok(HookPreToolAction::Deny(message)) => {
assert!(message.contains("permission ask unsupported"));
assert!(message.contains("denied fail-closed"));
}
+4 -4
View File
@@ -23,14 +23,14 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct SessionEntryRef(String);
pub struct SessionEntryRef(String);
impl SessionEntryRef {
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
pub fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
Self(format!("E{}", entry_id.0))
}
pub(crate) fn parse(value: &str) -> Option<Self> {
pub fn parse(value: &str) -> Option<Self> {
let suffix = value.strip_prefix('E')?;
if suffix.is_empty()
|| suffix.len() > 64
@@ -43,7 +43,7 @@ impl SessionEntryRef {
Some(Self(value.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
pub fn as_str(&self) -> &str {
&self.0
}
+5 -2
View File
@@ -70,9 +70,12 @@ impl TicketIntakeReadyShutdownHook {
#[async_trait]
impl Hook<PostToolCall> for TicketIntakeReadyShutdownHook {
async fn call(&self, info: &ToolResultSummary) -> HookPostToolAction {
async fn call(
&self,
info: &ToolResultSummary,
) -> Result<HookPostToolAction, crate::hook::HookError> {
self.observe_tool_result(info);
HookPostToolAction::Continue
Ok(HookPostToolAction::Continue)
}
}
+28 -125
View File
@@ -2,127 +2,11 @@ use serde::{Deserialize, Serialize};
use crate::worker::WorkspaceClient;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SkillDiagnosticSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillDiagnostic {
pub severity: SkillDiagnosticSeverity,
pub code: String,
pub message: String,
/// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`.
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl SkillDiagnostic {
pub fn error(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> Self {
Self {
severity: SkillDiagnosticSeverity::Error,
code: code.into(),
message: message.into(),
source,
}
}
pub fn warning(
code: impl Into<String>,
message: impl Into<String>,
source: Option<String>,
) -> Self {
Self {
severity: SkillDiagnosticSeverity::Warning,
code: code.into(),
message: message.into(),
source,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SkillSourceKind {
Builtin,
Workspace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillProvenance {
pub kind: SkillSourceKind,
/// Stable id: `builtin:<name>` or `workspace:<name>`.
pub id: String,
/// Virtual config/resource path. Never an absolute host filesystem path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub virtual_path: Option<String>,
/// Active Workspace config revision for Workspace Skills.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
/// Digest of the immutable `SKILL.md` source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_digest: Option<String>,
/// Digest of the active virtual config tree snapshot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree_digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillResourceRef {
pub kind: String,
/// Skill-relative resource name/path. Never an absolute filesystem path.
pub name: String,
pub supported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCatalogEntry {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillCatalogResponse {
/// Authority label for diagnostics; callers must not interpret it as a path.
pub authority: String,
#[serde(default)]
pub entries: Vec<SkillCatalogEntry>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillDetailResponse {
pub name: String,
pub description: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub overrides: Vec<SkillProvenance>,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// Imported Markdown content with YAML frontmatter delimiters removed.
/// This is intentionally omitted from catalog responses.
pub body: String,
#[serde(default)]
pub allowed_tools: Vec<String>,
/// Explicitly documents that allowed-tools is parsed only as an experimental hint.
pub allowed_tools_status: String,
#[serde(default)]
pub resources: Vec<SkillResourceRef>,
}
pub use workspace_api::{
SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus,
SkillProvenance, SkillResourceRef, SkillSourceKind,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillActivationResponse {
@@ -144,6 +28,8 @@ pub enum SkillClientError {
Request(#[from] crate::worker::WorkspaceClientError),
#[error("Skill API response JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("Skill API response violates the shared contract: {0}")]
InvalidResponse(#[from] workspace_api::SkillApiValidationError),
#[error("Skill API returned HTTP {status}: {body}")]
Http {
status: reqwest::StatusCode,
@@ -155,11 +41,15 @@ pub enum SkillClientError {
impl dyn WorkspaceClient + '_ {
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
self.get_skill_json("skills")
let response: SkillCatalogResponse = self.get_skill_json("skills")?;
response.validate()?;
Ok(response)
}
pub fn read_skill(&self, name: &str) -> Result<SkillDetailResponse, SkillClientError> {
self.get_skill_json(&format!("skills/{name}"))
let response: SkillDetailResponse = self.get_skill_json(&format!("skills/{name}"))?;
response.validate()?;
Ok(response)
}
pub fn activate_skill(&self, name: &str) -> Result<SkillActivationResponse, SkillClientError> {
@@ -229,11 +119,24 @@ mod tests {
assert_eq!(worker_header, None);
assert_eq!(authorization, None);
let body = serde_json::json!({
"authority": "workspace-backend-skills-v0",
"authority": "workspace-config-skills-v1",
"projection": {
"config_revision": 7,
"tree_digest": "tree-digest"
},
"entries": [{
"name": "triage-errors",
"description": "Use when triaging errors.",
"provenance": { "kind": "workspace", "id": "workspace:triage-errors" },
"activation_status": "active",
"projection_status": "valid",
"provenance": {
"kind": "workspace",
"id": "workspace:triage-errors",
"virtual_path": "skills/triage-errors/SKILL.md",
"revision": 7,
"source_digest": "source-digest",
"tree_digest": "tree-digest"
},
"overrides": [],
"diagnostics": []
}],
+57 -5
View File
@@ -425,6 +425,8 @@ impl Tool for SubWorkerSpawnTool {
WorkerManifestConfig::resolution_defaults().merge(child_config),
)
.map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?;
bind_child_memory_settings(&self.spawner_manifest, &mut child_manifest)
.map_err(ToolError::ExecutionFailed)?;
// Delegated children stay bound to their scoped session and cannot use
// Workspace attachment tools to replace it with parent-level authority.
child_manifest.feature.manage_workdir.enabled = false;
@@ -827,6 +829,33 @@ fn profile_error_with_available(error: ProfileError, available: &AvailableProfil
)
}
fn bind_child_memory_settings(
parent: &manifest::WorkerManifest,
child: &mut manifest::WorkerManifest,
) -> Result<(), String> {
if !child.feature.memory.profile.enabled {
return child
.feature
.memory
.validate_execution()
.map_err(str::to_string);
}
let workspace_settings = parent.feature.memory.workspace_settings().ok_or_else(|| {
"enabled child Memory feature requires the parent's trusted Workspace settings snapshot"
.to_string()
})?;
child
.feature
.memory
.bind_workspace_settings(workspace_settings)
.map_err(str::to_string)?;
child
.feature
.memory
.validate_execution()
.map_err(str::to_string)
}
fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
WorkerManifestConfig {
worker: WorkerMetaConfig {
@@ -894,7 +923,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
model: c.model.clone(),
}),
web: manifest.web.clone(),
memory: manifest.memory.clone(),
skills: manifest.skills.clone(),
}
}
@@ -1091,10 +1119,7 @@ enabled = true
thread = true
[feature.memory]
enabled = true
[memory]
extract_threshold = 4000
enabled = false
"#;
#[tokio::test]
@@ -1526,6 +1551,33 @@ extract_threshold = 4000
.unwrap()
}
#[test]
fn child_memory_inherits_only_the_parents_trusted_settings_snapshot() {
let temp = tempfile::tempdir().unwrap();
let mut parent = parent_manifest(temp.path(), None);
parent.feature.memory.profile.enabled = true;
parent
.feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".to_string(),
settings_revision: 4,
language: "日本語".to_string(),
})
.unwrap();
let mut child = parent.clone();
child.feature.memory.workspace_settings = None;
bind_child_memory_settings(&parent, &mut child).unwrap();
assert_eq!(
child.feature.memory.workspace_settings(),
parent.feature.memory.workspace_settings()
);
child.feature.memory.profile.enabled = false;
assert!(bind_child_memory_settings(&parent, &mut child).is_err());
}
fn write_project_profile_registry(
project: &Path,
default: Option<&str>,
+538 -1482
View File
File diff suppressed because it is too large Load Diff
-238
View File
@@ -578,138 +578,6 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
assert_eq!(new_id_in_event, Some(worker.segment_id()));
}
/// Regression: `Worker::compact()` must reset the in-memory
/// `extract_pointer` so extract keeps firing on the new compacted
/// session.
///
/// Without the reset, the pointer's `processed_through_history_len`
/// holds the old (typically large) item count, while the new compacted
/// session starts with a much shorter history (`[summary, ...]`).
/// `cumulative_input_tokens_since` would then filter every new
/// usage record out (their `history_len` is below the stale pointer)
/// and extract would never re-fire for the rest of the process.
const EXTRACT_PLUS_COMPACT_MANIFEST: &str = r#"
[worker]
name = "test-worker"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[compaction]
compact_threshold = 1
compact_retained_tokens = 0
[[scope.allow]]
target = "./"
permission = "write"
"#;
fn finish_memory_extraction_tool_use_events(call_id: &str) -> Vec<LlmEvent> {
let input = serde_json::json!({
"staged_count": 0,
"no_candidates_reason": "test run has no durable candidates"
})
.to_string();
vec![
LlmEvent::tool_use_start(0, call_id, "FinishMemoryExtraction"),
LlmEvent::tool_input_delta(0, input),
LlmEvent::tool_use_stop(0),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
#[tokio::test]
async fn compact_resets_extract_pointer_so_extract_can_fire_again() {
// Mock LLM responses, in call order:
// [0] first run with usage(1000) so extract threshold (=1) fires.
// [1] extract worker invokes FinishMemoryExtraction with empty output.
// [2] extract worker closes after the tool result.
// [3] compact worker invokes write_summary.
// [4] compact worker closes after the tool result.
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
write_summary_tool_use_events("sc1", "summary"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(EXTRACT_PLUS_COMPACT_MANIFEST, client).await;
worker.run_text("first").await.unwrap();
// extract fires; pointer becomes Some.
worker.try_post_run_extract().await.unwrap();
assert!(
worker.extract_pointer().is_some(),
"extract_pointer should be Some after a successful extract"
);
// Compact runs. Without the fix the in-memory pointer would still
// reference the old Segment's history_len.
worker.try_pre_run_compact().await;
assert!(
worker.extract_pointer().is_none(),
"extract_pointer must be reset to None after compact (matches cold-restore on the new Segment)"
);
}
/// `extract_threshold = 0` is treated as "disabled" — without this, a
/// raw `>=` comparison against `tokens_since` would fire extract on
/// every post-run regardless of activity. Mirrors the consolidation
/// zero-threshold convention so users have a single way to opt out
/// without removing the `[memory]` section.
const EXTRACT_THRESHOLD_ZERO_MANIFEST: &str = r#"
[worker]
name = "test-worker"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[memory]
extract_threshold = 0
[[scope.allow]]
target = "./"
permission = "write"
"#;
#[tokio::test]
async fn extract_threshold_zero_is_disabled() {
// Mock provides exactly one response — the first run. If extract
// were treated as "fire on any change" because of `tokens_since >= 0`,
// it would call into the extract worker and exhaust the mock.
let client = MockClient::new(vec![text_events_with_usage("hi", 1000)]);
let mut worker = make_worker_with_manifest(EXTRACT_THRESHOLD_ZERO_MANIFEST, client).await;
worker.run_text("first").await.unwrap();
worker
.try_post_run_extract()
.await
.expect("extract_threshold=0 must skip silently, not fail");
assert!(
worker.extract_pointer().is_none(),
"no extract should have run — pointer must remain None"
);
}
#[tokio::test]
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
// Only the first run has a response. Compaction will run the
@@ -746,112 +614,6 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
);
}
// ---------------------------------------------------------------------------
// Detached post-run memory jobs (`spawn_post_run_memory_jobs` /
// `wait_for_memory_jobs`). Covers the detach round-trip and the structural
// invariant that the cloned memory-task Worker shares `SegmentState` with the
// source Worker, so that `save_extension` from the background extract does not
// leave the next turn's `save_user_input` looking at a stale session pointer.
const EXTRACT_NO_COMPACT_MANIFEST: &str = r#"
[worker]
name = "test-worker"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1
[[scope.allow]]
target = "./"
permission = "write"
"#;
#[tokio::test]
async fn extract_large_unprocessed_range_does_not_abort_on_input_occupancy() {
let client = MockClient::new(vec![
text_events_with_usage("recorded", 1000),
finish_memory_extraction_tool_use_events("ec-large"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
let large_request = format!("remember this large slice: {}", "x ".repeat(200_000));
worker.run_text(&large_request).await.unwrap();
worker.try_post_run_extract().await.expect(
"large unprocessed extract ranges must reach the extract worker, not abort locally",
);
assert!(
worker.extract_pointer().is_some(),
"successful extract should advance the pointer even when the input range is large"
);
}
#[tokio::test]
async fn spawn_and_wait_drives_extract_to_completion() {
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
worker.run_text("first").await.unwrap();
assert!(
worker.extract_pointer().is_none(),
"extract has not run yet — pointer must be None"
);
worker.spawn_post_run_memory_jobs();
worker.wait_for_memory_jobs().await;
assert!(
worker.extract_pointer().is_some(),
"spawn + wait must complete extract; pointer should be set"
);
}
#[tokio::test]
async fn detached_extract_does_not_fork_session_log() {
// Source worker and the cloned memory-task worker share `SegmentState` via
// `Arc<_>`. The detached extract advances the entry tally through
// `save_extension`; the next `run` must see that same tally so
// `ensure_head_or_fork` does not spawn a new session.
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
finish_memory_extraction_tool_use_events("ec1"),
single_text_events("done"),
text_events_with_usage("ok", 1000),
]);
let mut worker = make_worker_with_manifest(EXTRACT_NO_COMPACT_MANIFEST, client).await;
worker.run_text("first").await.unwrap();
let session_before = worker.segment_id();
worker.spawn_post_run_memory_jobs();
worker.wait_for_memory_jobs().await;
worker.run_text("second").await.unwrap();
let session_after = worker.segment_id();
assert_eq!(
session_before, session_after,
"detached extract's save_extension and the next turn's save_user_input \
must share the entry tally through SegmentState a fork here means the \
clone carried its own counter"
);
}
#[tokio::test]
async fn controller_compact_method_emits_start_and_done() {
let client = MockClient::new(vec![
+13 -1
View File
@@ -795,13 +795,25 @@ permission = "write"
#[tokio::test]
async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() {
let workspace = tempfile::tempdir().unwrap();
let resolved = ProfileResolver::new()
let mut resolved = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
)
.unwrap();
if resolved.manifest.feature.memory.enabled() {
resolved
.manifest
.feature
.memory
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-test".to_string(),
settings_revision: 1,
language: "English".to_string(),
})
.unwrap();
}
let workspace_context =
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient));
let client = MockClient::new(simple_text_events());
+19 -1
View File
@@ -7,11 +7,13 @@ publish = false
[features]
default = []
typescript = ["dep:ts-rs"]
typescript = ["dep:ts-rs", "protocol/typescript"]
[dependencies]
protocol.workspace = true
serde = { workspace = true, features = ["derive"] }
ts-rs = { version = "12.0.1", optional = true }
webauthn-rs-proto = "0.5.5"
[[example]]
name = "generate_typescript"
@@ -24,10 +26,26 @@ serde_json.workspace = true
name = "generate_workdir_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_worker_launch_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_companion_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_memory_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_skill_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_auth_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_repository_access_types"
required-features = ["typescript"]
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::auth_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::memory_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::skill_api_typescript());
}
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::worker_launch_api_typescript());
}
File diff suppressed because it is too large Load Diff
+1 -44
View File
@@ -1,54 +1,11 @@
use axum::http::{HeaderMap, header};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub use workspace_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor};
use crate::{Error, Result, store::ControlPlaneStore};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthPublicConfig {
pub rp_id: String,
pub origin: String,
pub public_base_url: String,
pub cookie_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestActor {
pub user_id: String,
pub account_id: String,
pub handle: String,
pub display_name: String,
pub auth_method: ActorAuthMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActorAuthMethod {
BrowserSession,
ApiToken,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthenticatedUser {
pub user_id: String,
pub account_id: String,
pub handle: String,
pub display_name: String,
}
impl RequestActor {
pub fn user(&self) -> AuthenticatedUser {
AuthenticatedUser {
user_id: self.user_id.clone(),
account_id: self.account_id.clone(),
handle: self.handle.clone(),
display_name: self.display_name.clone(),
}
}
}
pub fn normalize_handle(handle: &str) -> Result<String> {
let normalized = handle.trim().to_ascii_lowercase();
let valid = !normalized.is_empty()
@@ -101,7 +101,6 @@ fn main_config_contract_with_schema(
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct WorkspaceConfigState {
pub snapshot: ConfigTreeSnapshot,
pub contract: ToolchainContract,
@@ -118,7 +117,6 @@ pub struct EvaluatedConfigCandidate {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct ConfigCommitRequest {
#[ts(type = "number")]
pub base_revision: u64,
@@ -1513,33 +1511,6 @@ mod tests {
assert_eq!(revision, first.snapshot);
}
#[tokio::test]
async fn migration_materializes_main_for_existing_workspace_without_config() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::configure_sqlite(&conn).unwrap();
crate::store::apply_migrations_through(&conn, 30).unwrap();
conn.execute(
"INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at
) VALUES ('legacy', 'Legacy', 'active', '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z')",
[],
)
.unwrap();
crate::store::persist_workspace_config_schema_bundles(&conn).unwrap();
crate::store::materialize_main_config_entrypoint(&conn).unwrap();
let state = load_state(&conn, "legacy").unwrap().unwrap();
assert!(
state
.snapshot
.entries
.contains_key(&path(MAIN_CONFIG_ENTRYPOINT))
);
assert_eq!(
state.contract.entrypoints,
vec![path(MAIN_CONFIG_ENTRYPOINT)]
);
}
#[test]
fn exports_typescript_transport_contract() {
use ts_rs::TS;
+156 -8
View File
@@ -23,10 +23,10 @@ use worker_runtime::RuntimeWorkspaceScope;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectorySummary, WorkspaceApiRef,
ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
WorkingDirectoryClaim, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)]
@@ -830,6 +830,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
))
}
fn observe_repository_ref(
&self,
_request: RepositoryRefObservationRequest,
) -> std::result::Result<RepositoryRefObservation, Error> {
Err(Error::RuntimeOperationFailed {
runtime_id: self.runtime_id().to_string(),
code: "repository_ref_provider_unavailable".to_string(),
message: "Runtime does not support Repository ref observation".to_string(),
})
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
RuntimeList::new(Vec::new(), Vec::new())
}
@@ -1449,6 +1460,31 @@ impl RuntimeRegistry {
})
}
pub fn observe_repository_ref(
&self,
runtime_id: &str,
request: RepositoryRefObservationRequest,
) -> Result<RepositoryRefObservation, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
let runtime = self.runtime(runtime_id)?;
runtime
.observe_repository_ref(request)
.map_err(|error| match error {
Error::RuntimeOperationFailed { code, message, .. } => {
RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code,
message,
}
}
other => RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "repository_ref_provider_unavailable".to_string(),
message: other.to_string(),
},
})
}
pub fn list_working_directories(
&self,
runtime_id: &str,
@@ -2143,6 +2179,28 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn observe_repository_ref(
&self,
request: RepositoryRefObservationRequest,
) -> std::result::Result<RepositoryRefObservation, Error> {
self.runtime
.observe_repository_ref(request)
.map_err(|error| match error {
worker_runtime::error::RuntimeError::WorkingDirectory(diagnostic) => {
Error::RuntimeOperationFailed {
runtime_id: self.runtime_id.clone(),
code: diagnostic.code,
message: diagnostic.message,
}
}
error => Error::RuntimeOperationFailed {
runtime_id: self.runtime_id.clone(),
code: "repository_ref_provider_unavailable".to_string(),
message: error.to_string(),
},
})
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
RuntimeList::new(Vec::new(), Vec::new())
}
@@ -3355,6 +3413,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
}
fn observe_repository_ref(
&self,
request: RepositoryRefObservationRequest,
) -> std::result::Result<RepositoryRefObservation, Error> {
self.post_json::<_, RepositoryRefObservation>("/v1/repository-refs/observe", &request)
.map_err(|diagnostic| Error::RuntimeOperationFailed {
runtime_id: self.runtime_id.clone(),
code: diagnostic.code,
message: diagnostic.message,
})
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
@@ -3772,7 +3842,6 @@ fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
EmbeddedWorkerStatus::Running => "running",
EmbeddedWorkerStatus::Paused => "paused",
EmbeddedWorkerStatus::Stopped => "stopped",
EmbeddedWorkerStatus::Cancelled => "cancelled",
}
}
@@ -4790,6 +4859,85 @@ mod tests {
}
}
struct ObservingExecutionBackend {
response: RepositoryRefObservation,
observed: Arc<Mutex<Vec<RepositoryRefObservationRequest>>>,
}
impl worker_runtime::execution::WorkerExecutionBackend for ObservingExecutionBackend {
fn backend_id(&self) -> &str {
"repository-observation-test-backend"
}
fn spawn_worker(
&self,
_request: worker_runtime::execution::WorkerExecutionSpawnRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
unreachable!("Repository observation test does not spawn Workers")
}
fn dispatch_input(
&self,
_handle: &worker_runtime::execution::WorkerExecutionHandle,
_input: EmbeddedWorkerInput,
) -> worker_runtime::execution::WorkerExecutionResult {
unreachable!("Repository observation test does not dispatch Worker input")
}
fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> Result<
RepositoryRefObservation,
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
> {
self.observed.lock().unwrap().push(request.clone());
Ok(self.response.clone())
}
}
#[test]
fn embedded_runtime_forwards_repository_ref_observation_to_execution_backend() {
let observed = Arc::new(Mutex::new(Vec::new()));
let expected = RepositoryRefObservation {
repository_id: "repository-1".to_string(),
source_revision: 7,
source_fingerprint: "sha256:source".to_string(),
selector: "refs/heads/published".to_string(),
revision_ref: "0123456789012345678901234567890123456789".to_string(),
observed_at_epoch_seconds: 42,
};
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"workspace-test",
Arc::new(ObservingExecutionBackend {
response: expected.clone(),
observed: observed.clone(),
}),
)
.unwrap();
let request = RepositoryRefObservationRequest {
repository: worker_runtime::catalog::WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: "/provider/repository.git".to_string(),
},
source_revision: 7,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
selector: "refs/heads/published".to_string(),
materialization: None,
};
assert_eq!(
runtime.observe_repository_ref(request.clone()).unwrap(),
expected
);
assert_eq!(observed.lock().unwrap().as_slice(), &[request]);
}
#[derive(Default)]
struct AcceptingExecutionBackend {
contexts:
@@ -5678,7 +5826,7 @@ mod tests {
json!({
"workers": [
worker_json_with_status("remote:primary", &worker_ids[0], "stopped"),
worker_json_with_status("remote:primary", &worker_ids[1], "cancelled"),
worker_json_with_status("remote:primary", &worker_ids[1], "running"),
worker_json_with_status("remote:primary", &worker_ids[2], "paused"),
worker_json_with_status("remote:primary", &worker_ids[3], "idle")
]
@@ -5717,11 +5865,11 @@ mod tests {
let workers = registry.list_workers(10);
assert_eq!(workers.items.len(), 4);
assert!(!workers.items[0].capabilities.can_stop);
assert!(!workers.items[1].capabilities.can_stop);
assert!(workers.items[1].capabilities.can_stop);
assert!(workers.items[2].capabilities.can_stop);
assert!(workers.items[3].capabilities.can_stop);
assert_eq!(workers.items[0].state, "stopped");
assert_eq!(workers.items[1].state, "cancelled");
assert_eq!(workers.items[1].state, "running");
assert_eq!(workers.items[2].state, "paused");
assert_eq!(workers.items[3].state, "idle");
File diff suppressed because it is too large Load Diff
+2 -59
View File
@@ -20,7 +20,6 @@ enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
Skills(SkillsCommand),
Help,
}
@@ -71,17 +70,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
let database = database.unwrap_or_else(ServerConfig::default_server_database_path);
let plan = SqliteWorkspaceStore::migration_plan(&database).map_err(|error| {
CliError(format!(
"migration dry-run failed for {}: {error}",
database.display()
))
})?;
println!("{}", serde_json::to_string_pretty(&plan)?);
Ok(())
}
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
}
@@ -96,7 +84,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
"skills" => parse_skills_command(rest),
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -625,32 +612,6 @@ fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBu
))
}
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
let mut dry_run = false;
let mut database = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--dry-run" => dry_run = true,
"--database" => {
index += 1;
database =
Some(PathBuf::from(args.get(index).ok_or_else(|| {
CliError("--database requires a path".to_string())
})?));
}
value => {
return Err(CliError(format!("unknown migrate option: {value}")));
}
}
index += 1;
}
if !dry_run {
return Err(CliError("migrate currently requires --dry-run".to_string()));
}
Ok(Command::MigrateDryRun { database })
}
fn parse_skills_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_skills_help();
@@ -770,8 +731,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -783,8 +743,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
);
}
@@ -823,22 +782,6 @@ mod tests {
assert_eq!(name, "debug-rust");
}
#[test]
fn parse_migrate_requires_dry_run_and_accepts_database_path() {
let error = parse_migrate_command(&[]).unwrap_err();
assert_eq!(error.to_string(), "migrate currently requires --dry-run");
let command = parse_migrate_command(&[
"--dry-run".to_string(),
"--database".to_string(),
"/tmp/server.db".to_string(),
])
.unwrap();
let Command::MigrateDryRun { database } = command else {
panic!("expected migration dry-run command");
};
assert_eq!(database, Some(PathBuf::from("/tmp/server.db")));
}
#[test]
fn parse_serve_accepts_listen_and_host_config() {
let args = vec![
+179 -69
View File
@@ -1,6 +1,10 @@
use memory::extract::StagingRecord;
use memory::schema::{SourceEvidenceRef, SourceRef};
use serde::{Deserialize, Serialize};
use memory::extract::{CandidateKind, StagingRecord};
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef};
use workspace_api::{
Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin,
MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry,
MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord,
};
use crate::Result;
use crate::authority::MemoryAuthority;
@@ -8,59 +12,6 @@ use crate::authority::MemoryAuthority;
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
const MAX_MEMORY_STAGING_LIMIT: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingListResponse {
pub limit: usize,
pub returned_count: usize,
pub total_valid_count: usize,
pub invalid_count: usize,
pub truncated: bool,
pub order: String,
pub record_authority: String,
pub items: Vec<MemoryStagingEntrySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingEntrySummary {
pub id: String,
pub byte_len: u64,
pub record: MemoryStagingRecordSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingRecordSummary {
pub schema_version: u32,
pub id: String,
pub extract_run_id: String,
pub source: SourceRef,
pub kind: String,
pub claim: String,
pub why_useful: String,
pub staleness: Option<String>,
pub evidence: Vec<MemoryStagingEvidenceSummary>,
pub source_refs: Vec<MemorySourceEvidenceRefSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemoryStagingEvidenceSummary {
pub id: String,
pub kind: String,
pub entry_range: Option<[u64; 2]>,
pub excerpt: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemorySourceEvidenceRefSummary {
pub session_id: Option<String>,
pub segment_id: Option<String>,
pub entry_range: Option<[u64; 2]>,
pub evidence_id: Option<String>,
pub evidence_kind: Option<String>,
pub label: Option<String>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryStagingBacklogSummary {
pub candidate_count: usize,
@@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
};
total_valid_count += 1;
if valid_items.len() < limit {
valid_items.push(MemoryStagingEntrySummary {
valid_items.push(MemoryStagingEntry {
id: entry.candidate_id,
byte_len: entry.raw_json.len() as u64,
record: memory_staging_record_summary(record),
record: memory_staging_record_projection(record),
});
}
}
let returned_count = valid_items.len();
let diagnostics = (invalid_count > 0)
.then(|| Diagnostic {
code: "memory_staging_record_invalid".to_string(),
message: format!(
"{invalid_count} Memory staging record(s) were excluded because they did not match the current schema."
),
severity: DiagnosticSeverity::Error,
})
.into_iter()
.collect();
Ok(MemoryStagingListResponse {
limit,
returned_count,
@@ -107,6 +68,7 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
order: "imported_at_desc_candidate_id_asc".to_string(),
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
items: valid_items,
diagnostics,
})
}
@@ -133,23 +95,24 @@ pub fn memory_staging_backlog_from_authority<A: MemoryAuthority>(
})
}
fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSummary {
MemoryStagingRecordSummary {
fn memory_staging_record_projection(record: StagingRecord) -> MemoryStagingRecord {
MemoryStagingRecord {
schema_version: record.schema_version,
id: record.id,
extract_run_id: record.extract_run_id,
source: record.source,
kind: record.kind.as_str().to_string(),
source: memory_source_ref_projection(record.source),
kind: memory_candidate_kind_projection(record.kind),
claim: record.claim,
why_useful: record.why_useful,
staleness: record.staleness,
evidence: record
.evidence
.into_iter()
.map(|evidence| MemoryStagingEvidenceSummary {
.map(|evidence| MemoryStagingEvidence {
id: evidence.id,
kind: evidence.kind.as_str().to_string(),
entry_range: evidence.entry_range,
origin: evidence.origin.map(memory_evidence_origin_projection),
excerpt: evidence.excerpt,
summary: evidence.summary,
})
@@ -157,19 +120,36 @@ fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSu
source_refs: record
.source_refs
.into_iter()
.map(memory_source_evidence_ref_summary)
.map(memory_source_evidence_ref_projection)
.collect(),
}
}
fn memory_source_evidence_ref_summary(
source_ref: SourceEvidenceRef,
) -> MemorySourceEvidenceRefSummary {
MemorySourceEvidenceRefSummary {
fn memory_source_ref_projection(source_ref: SourceRef) -> MemorySourceRef {
MemorySourceRef {
segment_id: source_ref.segment_id,
range: source_ref.range,
}
}
fn memory_candidate_kind_projection(kind: CandidateKind) -> MemoryCandidateKind {
match kind {
CandidateKind::Preference => MemoryCandidateKind::Preference,
CandidateKind::WorkingAssumption => MemoryCandidateKind::WorkingAssumption,
CandidateKind::Constraint => MemoryCandidateKind::Constraint,
CandidateKind::Decision => MemoryCandidateKind::Decision,
CandidateKind::OpenQuestion => MemoryCandidateKind::OpenQuestion,
CandidateKind::Lesson => MemoryCandidateKind::Lesson,
}
}
fn memory_source_evidence_ref_projection(source_ref: SourceEvidenceRef) -> MemorySourceEvidenceRef {
MemorySourceEvidenceRef {
session_id: source_ref.session_id,
segment_id: source_ref.segment_id,
entry_range: source_ref.entry_range,
evidence_id: source_ref.evidence_id,
origin: source_ref.origin.map(memory_evidence_origin_projection),
evidence_kind: source_ref
.evidence_kind
.map(|evidence_kind| evidence_kind.as_str().to_string()),
@@ -178,12 +158,35 @@ fn memory_source_evidence_ref_summary(
}
}
fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOrigin {
MemoryEvidenceOrigin {
kind: match origin.kind {
EvidenceOriginKind::HumanInput => MemoryEvidenceOriginKind::HumanInput,
EvidenceOriginKind::WorkerInput => MemoryEvidenceOriginKind::WorkerInput,
EvidenceOriginKind::FlowInstruction => MemoryEvidenceOriginKind::FlowInstruction,
EvidenceOriginKind::BackendInstruction => MemoryEvidenceOriginKind::BackendInstruction,
EvidenceOriginKind::ModelOutput => MemoryEvidenceOriginKind::ModelOutput,
EvidenceOriginKind::ToolOutput => MemoryEvidenceOriginKind::ToolOutput,
EvidenceOriginKind::DerivedSummary => MemoryEvidenceOriginKind::DerivedSummary,
EvidenceOriginKind::LegacyUnknown => MemoryEvidenceOriginKind::LegacyUnknown,
},
account_id: origin.account_id,
workspace_id: origin.workspace_id,
runtime_id: origin.runtime_id,
worker_id: origin.worker_id,
flow_selector: origin.flow_selector,
flow_definition_id: origin.flow_definition_id,
flow_definition_revision: origin.flow_definition_revision,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef};
use tempfile::TempDir;
fn source() -> SourceRef {
@@ -258,7 +261,114 @@ mod tests {
response.record_authority,
"sqlite_workspace_authority.memory_staging"
);
assert_eq!(response.items[0].record.kind, "decision");
assert_eq!(response.items[0].record.kind, MemoryCandidateKind::Decision);
assert!(response.diagnostics.is_empty());
}
#[test]
fn projects_every_typed_evidence_origin_without_flattening() {
let cases = [
(
EvidenceOriginKind::HumanInput,
MemoryEvidenceOriginKind::HumanInput,
),
(
EvidenceOriginKind::WorkerInput,
MemoryEvidenceOriginKind::WorkerInput,
),
(
EvidenceOriginKind::FlowInstruction,
MemoryEvidenceOriginKind::FlowInstruction,
),
(
EvidenceOriginKind::BackendInstruction,
MemoryEvidenceOriginKind::BackendInstruction,
),
(
EvidenceOriginKind::ModelOutput,
MemoryEvidenceOriginKind::ModelOutput,
),
(
EvidenceOriginKind::ToolOutput,
MemoryEvidenceOriginKind::ToolOutput,
),
(
EvidenceOriginKind::DerivedSummary,
MemoryEvidenceOriginKind::DerivedSummary,
),
(
EvidenceOriginKind::LegacyUnknown,
MemoryEvidenceOriginKind::LegacyUnknown,
),
];
for (domain_kind, api_kind) in cases {
let projected = memory_source_evidence_ref_projection(SourceEvidenceRef {
session_id: Some("session-1".to_string()),
origin: Some(EvidenceOrigin {
kind: domain_kind,
account_id: Some("account-1".to_string()),
workspace_id: Some("workspace-test".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: Some("worker-1".to_string()),
flow_selector: Some("builtin:coder-review".to_string()),
flow_definition_id: Some("flow-1".to_string()),
flow_definition_revision: Some(7),
}),
..SourceEvidenceRef::default()
});
let origin = projected.origin.unwrap();
assert_eq!(origin.kind, api_kind);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
assert_eq!(origin.workspace_id.as_deref(), Some("workspace-test"));
assert_eq!(origin.runtime_id.as_deref(), Some("runtime-1"));
assert_eq!(origin.worker_id.as_deref(), Some("worker-1"));
assert_eq!(
origin.flow_selector.as_deref(),
Some("builtin:coder-review")
);
assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1"));
assert_eq!(origin.flow_definition_revision, Some(7));
}
}
#[tokio::test]
async fn invalid_or_newer_origin_shapes_are_excluded_with_bounded_diagnostic() {
let (_temp, authority) = authority().await;
for (id, origin) in [
(
"unknown-origin-kind",
serde_json::json!({"kind": "future_origin_kind"}),
),
(
"newer-origin-shape",
serde_json::json!({"kind": "human_input", "future_field": "do not echo me"}),
),
] {
let mut record: serde_json::Value =
serde_json::from_str(&record_json(id, "claim")).unwrap();
record["source_refs"] = serde_json::json!([{"origin": origin}]);
authority
.upsert_memory_staging_record(id, &serde_json::to_string(&record).unwrap(), None)
.unwrap();
}
let response = list_memory_staging_from_authority(&authority, None).unwrap();
assert_eq!(response.returned_count, 0);
assert_eq!(response.invalid_count, 2);
assert_eq!(response.diagnostics.len(), 1);
assert_eq!(
response.diagnostics[0].code,
"memory_staging_record_invalid"
);
assert_eq!(response.diagnostics[0].severity, DiagnosticSeverity::Error);
assert!(
!response.diagnostics[0]
.message
.contains("future_origin_kind")
);
assert!(!response.diagnostics[0].message.contains("do not echo me"));
}
#[tokio::test]
+10
View File
@@ -314,12 +314,21 @@ pub struct TicketMergeRequestSummary {
pub review_excerpt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestRefDiagnostic {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestListItem {
pub summary: TicketMergeRequestSummary,
pub ticket_ids: Vec<String>,
pub thread_event_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ref_diagnostics: Vec<MergeRequestRefDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -470,6 +479,7 @@ pub fn ticket_api_typescript() -> String {
TicketAssignmentPrincipalSummary::decl(&config),
TicketActionEligibility::decl(&config),
TicketMergeRequestSummary::decl(&config),
MergeRequestRefDiagnostic::decl(&config),
MergeRequestListItem::decl(&config),
MergeRequestListResponse::decl(&config),
TicketEvidenceSummary::decl(&config),
+1 -1
View File
@@ -392,7 +392,7 @@ impl RepositoryRegistryReader {
}
}
fn normalize_target_branch_selector(
pub(crate) fn normalize_target_branch_selector(
id: &str,
selector: &str,
) -> Result<String, RepositoryLookupError> {
-127
View File
@@ -163,101 +163,6 @@ pub enum WorkerRetentionError {
Invalid(String),
}
pub(crate) fn repair_worker_diagnostics_archive_table(conn: &Connection) -> crate::Result<bool> {
let existed: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='worker_diagnostics_archives')",
[],
|row| row.get(0),
)?;
if !existed {
conn.execute_batch(
"CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);",
)?;
}
Ok(!existed)
}
pub(crate) fn create_worker_retention_tables(conn: &Connection) -> crate::Result<()> {
conn.execute_batch(r#"
CREATE TABLE workspace_worker_retention_policy_revisions (
workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>0),
session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')),
metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')),
archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')),
archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')),
diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,policy_id,revision),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE workspace_worker_retention_policies (
workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,policy_id,revision) REFERENCES workspace_worker_retention_policy_revisions(workspace_id,policy_id,revision),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_removal_operations (
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0),
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
diagnostics_disposition TEXT NOT NULL,
diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')),
reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at);
CREATE TABLE worker_session_archives (
archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL,
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_diagnostics_archives (
operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
committed_at TEXT NOT NULL, expires_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
archive_id TEXT, policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL UNIQUE,
PRIMARY KEY(workspace_id,runtime_id,worker_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id),
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_orphan_diagnostics (
diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_retention_audit_events (
event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
INSERT INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,'archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at);
INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
VALUES(NEW.workspace_id,'workspace-default-conservative',1,NEW.created_at);
END;
"#)?;
let now = Utc::now().to_rfc3339();
conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
SELECT workspace_id,?1,1,'archive','tombstone','forever',NULL,'purge',NULL,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?;
conn.execute("INSERT OR IGNORE INTO workspace_worker_retention_policies(workspace_id,policy_id,revision,updated_at)
SELECT workspace_id,?1,1,?2 FROM workspaces", params![CONSERVATIVE_POLICY_ID,now])?;
Ok(())
}
impl SqliteWorkspaceStore {
pub fn worker_retention_policy(
&self,
@@ -1724,36 +1629,4 @@ mod tests {
);
assert_eq!(recovered.plan.state, WorkerRemovalPlanState::Failed);
}
#[test]
fn old_schema_upgrade_seeds_existing_workspace() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
{
let connection = rusqlite::Connection::open(&path).unwrap();
crate::store::configure_sqlite(&connection).unwrap();
crate::store::apply_migrations_through(&connection, 27).unwrap();
connection
.execute(
"INSERT INTO accounts(
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner-account', 'Owner Account', 'old', 'old')",
[],
)
.unwrap();
connection
.execute(
"INSERT INTO workspaces(
workspace_id, display_name, state, created_at, updated_at, owner_account_id
) VALUES ('legacy', 'Legacy', 'active', 'old', 'old', 'owner-account')",
[],
)
.unwrap();
}
let reopened = SqliteWorkspaceStore::open(&path).unwrap();
let p = reopened.worker_retention_policy("legacy").unwrap().unwrap();
assert_eq!(p.policy_id, CONSERVATIVE_POLICY_ID);
assert_eq!(p.session_disposition, SessionDisposition::Archive);
assert_eq!(p.metadata_disposition, MetadataDisposition::Tombstone);
}
}
File diff suppressed because it is too large Load Diff
+59 -8
View File
@@ -4,9 +4,11 @@ use config_source::{
ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document,
};
use serde::Deserialize;
use worker::skill::{
SkillActivationResponse, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
SkillDiagnostic, SkillDiagnosticSeverity, SkillProvenance, SkillResourceRef, SkillSourceKind,
use worker::skill::SkillActivationResponse;
use workspace_api::{
SKILL_CATALOG_AUTHORITY, SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse,
SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity,
SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind,
};
use crate::config_source::{
@@ -19,7 +21,6 @@ const BUILTIN_SKILL_VIRTUAL_PATH: &str = "builtin/skills/agent-skills/SKILL.md";
const SKILL_SCHEMA_PROVIDER_ID: &str = "builtin:skills";
const SKILL_SCHEMA_NAMESPACE: &str = "skills";
const SKILL_SCHEMA_VERSION: &str = "1";
const SKILL_CATALOG_AUTHORITY: &str = "workspace-config-skills-v1";
/// Skill documents are values imported from `SKILL.md`. Known Agent Skills
/// frontmatter is typed while extension keys remain concrete values.
@@ -101,32 +102,54 @@ pub fn catalog(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, Ski
.into_values()
.map(|skill| skill.catalog_entry())
.collect();
Ok(SkillCatalogResponse {
let response = SkillCatalogResponse {
authority: SKILL_CATALOG_AUTHORITY.to_string(),
projection: projection_identity(state),
entries,
diagnostics: Vec::new(),
})
};
response
.validate()
.map_err(|error| SkillError::InvalidProjection(error.to_string()))?;
Ok(response)
}
pub fn lint(state: &WorkspaceConfigState) -> Result<SkillCatalogResponse, SkillError> {
catalog(state)
}
fn projection_identity(state: &WorkspaceConfigState) -> SkillProjectionIdentity {
SkillProjectionIdentity {
config_revision: state.snapshot.revision,
tree_digest: state.snapshot.digest.clone(),
}
}
pub fn detail(state: &WorkspaceConfigState, name: &str) -> Result<SkillDetailResponse, SkillError> {
let skill = merged_skills(state)?
.remove(name)
.ok_or_else(|| SkillError::NotFound(name.to_string()))?;
Ok(SkillDetailResponse {
let activation_status = skill.activation_status();
let projection_status = skill.projection_status();
let response = SkillDetailResponse {
authority: SKILL_CATALOG_AUTHORITY.to_string(),
projection: projection_identity(state),
name: skill.name,
description: skill.description,
provenance: skill.provenance,
overrides: skill.overrides,
diagnostics: skill.diagnostics,
activation_status,
projection_status,
body: skill.body,
allowed_tools: skill.allowed_tools,
allowed_tools_status: "experimental_hint_only".to_string(),
resources: skill.resources,
})
};
response
.validate()
.map_err(|error| SkillError::InvalidProjection(error.to_string()))?;
Ok(response)
}
pub fn activation(
@@ -415,10 +438,28 @@ impl ParsedSkill {
.any(|diagnostic| diagnostic.severity == SkillDiagnosticSeverity::Error)
}
fn activation_status(&self) -> SkillActivationStatus {
if self.has_errors() {
SkillActivationStatus::Inactive
} else {
SkillActivationStatus::Active
}
}
fn projection_status(&self) -> SkillProjectionStatus {
if self.has_errors() {
SkillProjectionStatus::Invalid
} else {
SkillProjectionStatus::Valid
}
}
fn catalog_entry(&self) -> SkillCatalogEntry {
SkillCatalogEntry {
name: self.name.clone(),
description: self.description.clone(),
activation_status: self.activation_status(),
projection_status: self.projection_status(),
provenance: self.provenance.clone(),
overrides: self.overrides.clone(),
diagnostics: self.diagnostics.clone(),
@@ -513,12 +554,20 @@ mod tests {
.unwrap();
assert_eq!(item.provenance.kind, SkillSourceKind::Workspace);
assert_eq!(item.provenance.revision, Some(9));
assert_eq!(catalog.projection.config_revision, 9);
assert_eq!(catalog.projection.tree_digest, state.snapshot.digest);
assert_eq!(item.activation_status, SkillActivationStatus::Active);
assert_eq!(item.projection_status, SkillProjectionStatus::Valid);
assert!(
item.diagnostics
.iter()
.all(|diagnostic| diagnostic.severity != SkillDiagnosticSeverity::Error)
);
let detail = detail(&state, "debug-rust").unwrap();
assert_eq!(detail.authority, SKILL_CATALOG_AUTHORITY);
assert_eq!(detail.projection.config_revision, 9);
assert_eq!(detail.activation_status, SkillActivationStatus::Active);
assert_eq!(detail.projection_status, SkillProjectionStatus::Valid);
assert_eq!(detail.body, "# Debug Rust\n");
assert_eq!(detail.allowed_tools, vec!["Read", "Grep"]);
assert_eq!(
@@ -579,6 +628,8 @@ mod tests {
.into_iter()
.find(|item| item.name == "debug-rust")
.unwrap();
assert_eq!(item.activation_status, SkillActivationStatus::Inactive);
assert_eq!(item.projection_status, SkillProjectionStatus::Invalid);
assert!(
item.diagnostics
.iter()
File diff suppressed because it is too large Load Diff
@@ -91,44 +91,6 @@ pub struct WorkdirRemovalGuard {
pub detail: &'static str,
}
pub(crate) fn create_workdir_removal_operations(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE workdir_removal_operations (
workspace_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
workdir_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
materialization_fingerprint TEXT NOT NULL,
source_actor TEXT NOT NULL,
reason TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)),
disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')),
failure_category TEXT,
attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0),
attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
ON workdir_removal_operations(workspace_id, workdir_id)
WHERE state = 'pending';
CREATE INDEX idx_workdir_removal_operations_recovery
ON workdir_removal_operations(workspace_id, state, retryable, updated_at);
CREATE INDEX idx_workdir_removal_operations_workdir
ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC);
"#,
)?;
Ok(())
}
pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> String {
let bytes = serde_json::to_vec(&serde_json::json!([
record.workspace_id,
+1 -2
View File
@@ -13,9 +13,8 @@ pkgs.mkShell {
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
# sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded.
# Cache storage and limits belong to the host's sccache configuration.
RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache";
SCCACHE_CACHE_SIZE = "5G";
buildInputs = with pkgs; [
pkg-config
+1 -1
View File
@@ -23,7 +23,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
16. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them.
17. [`development/validation.md`](development/validation.md) — how to check changes.
18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes.
18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — the canonical SQLite baseline, compatibility fence, and manual dogfooding-data repair procedure.
19. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。
## What belongs here
+30 -32
View File
@@ -1,43 +1,41 @@
# Workspace database schema migration runbook
# Workspace database schema baseline
The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority.
The Workspace Server owns one control-plane SQLite database. New databases are created directly from the current canonical schema; the repository does not retain an executable chain of historical Workspace schema migrations.
## Before deployment
Domain components such as Ticket and Merge Request contribute their current tables to the same database, but they do not create a second Workspace authority.
1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration.
2. Record the current binary revision and database schema version.
3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure.
4. Run the read-only plan with the new binary:
## Compatibility boundary
```sh
yoi-server migrate --dry-run --database <server.db>
The Server accepts only the current canonical schema generation. Its `__yoi_schema_migrations` ledger must contain exactly one row naming that baseline. A database with an older, newer, or multi-generation Workspace migration history is rejected at startup.
This is intentional while Yoi has only the dogfooding deployment. Schema changes may replace the baseline rather than adding permanent compatibility code. Existing dogfooding data must be migrated manually and atomically before starting the new binary.
## Updating the dogfooding database
1. Stop every Server and Runtime process that can write the affected SQLite or Runtime stores.
2. Record the current binary revision and schema generation.
3. Take a SQLite-safe backup of `server.db` and a filesystem backup of any Runtime stores whose persisted contracts change.
4. Apply the data and schema repair explicitly. Keep Workspace SQL data and Runtime filesystem data as separate authorities; changing one does not repair the other.
5. Replace historical migration-ledger rows with the single marker expected by the current baseline.
6. Validate before startup:
```sql
PRAGMA foreign_key_check;
PRAGMA integrity_check;
```
The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying.
7. Start exactly one Server generation and verify the affected API contracts.
## Applying
There is no in-place down migration and no automatic upgrade from an old baseline. Rollback means restoring both the prior binary and the complete matching database and Runtime-store backups.
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
## Creating a new baseline
- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and
- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.
A baseline change must include:
After startup, verify:
- canonical DDL that creates a fresh database directly at the new generation;
- current-schema verification for Workspace, Ticket, and Merge Request tables;
- tests proving a fresh database records only the canonical baseline marker;
- an explicit, separately reviewed repair procedure for the current dogfooding data;
- removal of obsolete migration functions, fixtures, commands, and documentation.
```sql
SELECT MAX(version) FROM __yoi_schema_migrations;
PRAGMA foreign_key_check;
PRAGMA integrity_check;
```
The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`.
## Failure and rollback
There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying.
Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database.
Do not put temporary legacy interpretation into normal request or projection paths. If persisted Runtime data also changes identity or shape, repair that Runtime authority explicitly instead of teaching steady-state Workspace APIs to accept both contracts indefinitely.
+27 -41
View File
@@ -222,55 +222,41 @@ permission = "write"
# # ref = "anthropic/claude-haiku-4-5"
# ===== [memory] =============================================================
# Memory subsystem の opt-in。
# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、
# `<workspace>/memory/` と `<workspace>/`
# の通常 write を Worker 自体に対して deny する
# - セクションが *無い* … 何も起きない (legacy 動作)。
# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)
# [memory]
# ===== [feature.memory] ======================================================
# Memory は `feature.memory` だけを入口にする。resolved Worker Manifest では
# Profile由来の設定を `profile` に、Backend由来のWorkspace設定snapshotを
# `workspace_settings` に分離して保存する。`workspace_settings` はBackendだけが
# bindする信頼済み入力で、Profile・Browser・model入力から指定できない
# `profile.enabled = false` の場合、Memory tools、Feature prompt contributionによる
# resident injection、extract、consolidation requestをすべて無効にし、snapshotも保持しない
#
# # 任意。デフォルト: Worker の pwd (構築時)。
# # 必ず絶対パス (相対なら manifest base 起点で resolve)。
# workspace_root = "/abs/path/to/workspace"
# [feature.memory.profile]
# enabled = true
# staging_tools = false
#
# # 任意。デフォルト: tool 側既定 = 20。
# # MemoryQuery / MemoryQuery が 1 回に返す最大件数。
# query_result_limit = 20
# [feature.memory.profile.resident]
# inject_summary = true
#
# # 任意。デフォルト: tool 側既定 = 3。
# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。
# query_excerpt_lines = 3
# [feature.memory.profile.extraction]
# enabled = true
# threshold = 30000
# worker_max_turns = 8
#
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製
# # extract ワーカーのモデル ([model] と同じ形式)。
# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。
# # [memory.extract_model]
# # 任意。省略時はmain modelをcloneする
# # [feature.memory.profile.extraction.model]
# # ref = "anthropic/claude-haiku-4-5"
#
# # 任意。デフォルト: なし (extract 自動発火を完全停止)。
# # 前回 extract pointer 以降の累積入力 token がこの値を超えると extract 起動。
# # ※ memory tools と resident injection は extract_threshold が None でも動く。
# extract_threshold = 30000
# [feature.memory.profile.consolidation]
# request_enabled = true
#
# # 任意。デフォルト: 8 (`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`)
# # extract worker 自身の tool loop 上限。Rust config で None の場合のみ無制限。
# extract_worker_max_turns = 8
# # Backendがresolved Manifestへbindする。手書き/Profile入力では指定しない
# # [feature.memory.workspace_settings]
# # workspace_id = "workspace-id"
# # settings_revision = 1
# # language = "日本語"
#
# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。
# # consolidation ワーカーのモデル。reasoning クラス推奨
# # [memory.consolidation_model]
# # ref = "anthropic/claude-sonnet-4-6"
#
# # 任意。デフォルト: なし。
# # `_staging/` のエントリ数がこの値以上で consolidation 発火 (files / bytes は OR)。
# consolidation_threshold_files = 50
#
# # 任意。デフォルト: なし。
# # `_staging/` の総バイト数がこの値以上で consolidation 発火 (files / bytes は OR)。
# # files / bytes の両方が None だと consolidation 完全無効。
# consolidation_threshold_bytes = 1048576
# Query結果/抜粋の上限とconsolidation eligibility/thresholdはoperation/Backend
# policyが所有し、通常Worker Manifestには含めない。legacy `[memory]` は拒否する
# ===== [skills] =============================================================
+9 -7
View File
@@ -23,7 +23,15 @@ compaction = {
feature = {
task = { enabled = true; };
memory = { enabled = true; };
memory = {
enabled = true;
resident = { inject_summary = true; };
extraction = {
enabled = true;
threshold = 50000;
};
consolidation = { request_enabled = true; };
};
web = { enabled = true; };
image = { enabled = true; };
sub_worker = { enabled = false; };
@@ -40,12 +48,6 @@ feature = {
};
};
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = {
enabled = true;
search = {
+1 -1
View File
@@ -6,7 +6,7 @@ import "./base.dcdl" // {
feature = {
task = { enabled = true; };
memory = { enabled = false; staging = false; };
memory = { enabled = false; staging_tools = false; };
web = { enabled = true; };
image = { enabled = true; };
sub_worker = { enabled = true; };
+1 -1
View File
@@ -5,7 +5,7 @@ import "./base.dcdl" // {
feature = {
task = { enabled = false; };
memory = { enabled = true; staging = true; };
memory = { enabled = true; staging_tools = true; };
web = { enabled = false; };
sub_worker = { enabled = false; };
worker = { enabled = false; };
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
+107
View File
@@ -0,0 +1,107 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts
export type AuthPublicConfig = {
rp_id: string;
origin: string;
public_base_url: string;
cookie_name: string;
};
export type ActorAuthMethod = "browser_session" | "api_token";
export type AuthenticatedUser = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
};
export type RequestActor = {
user_id: string;
account_id: string;
handle: string;
display_name: string;
auth_method: ActorAuthMethod;
};
export type WhoamiResponse = { actor: RequestActor | null };
export type AuthBootstrapUserRequest = {
handle: string;
display_name?: string | null;
};
export type AuthUserResponse = { user: AuthenticatedUser };
export type PasskeyRegistrationOptionsRequest = {
handle: string;
display_name?: string | null;
browser_origin?: string | null;
};
export type PasskeyRegistrationOptionsResponse = {
challenge_id: string;
public_key: unknown;
};
export type PasskeyRegistrationCompleteRequest = {
challenge_id: string;
credential: unknown;
};
export type PasskeyLoginOptionsRequest = {
handle?: string | null;
browser_origin?: string | null;
};
export type PasskeyLoginOptionsResponse = {
challenge_id: string;
public_key: unknown;
};
export type PasskeyLoginCompleteRequest = {
challenge_id: string;
credential: unknown;
};
export type DeviceLoginStartRequest = { client_name?: string | null };
export type DeviceLoginStartResponse = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
};
export type DeviceLoginApproveRequest = { user_code: string };
export type DeviceLoginApprovalStatus = "approved";
export type DeviceLoginApproveResponse = {
status: DeviceLoginApprovalStatus;
user: AuthenticatedUser;
};
export type DeviceLoginPollRequest = { device_code: string };
export type DeviceAccessTokenType = "Bearer";
export type DeviceLoginPollStatus =
| "pending"
| "approved"
| "expired"
| "denied"
| "consumed";
export type DeviceLoginPollResponse = {
status: DeviceLoginPollStatus;
access_token?: string | null;
token_type?: DeviceAccessTokenType | null;
};
export type LogoutStatus = "logged_out";
export type LogoutResponse = { status: LogoutStatus };
@@ -0,0 +1,100 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type MemoryDocumentResponse = {
body_md: string;
created_at: string;
updated_at: string;
bytes: number;
record_source: string;
};
export type MemoryCandidateKind =
| "preference"
| "working_assumption"
| "constraint"
| "decision"
| "open_question"
| "lesson";
export type MemoryEvidenceOriginKind =
| "human_input"
| "worker_input"
| "flow_instruction"
| "backend_instruction"
| "model_output"
| "tool_output"
| "derived_summary"
| "legacy_unknown";
export type MemoryEvidenceOrigin = {
kind: MemoryEvidenceOriginKind;
account_id?: string | null;
workspace_id?: string | null;
runtime_id?: string | null;
worker_id?: string | null;
flow_selector?: string | null;
flow_definition_id?: string | null;
flow_definition_revision?: number | null;
};
export type MemorySourceRef = { segment_id: string; range: [number, number] };
export type MemoryStagingEvidence = {
id: string;
kind: string;
entry_range: [number, number] | null;
origin?: MemoryEvidenceOrigin | null;
excerpt: string | null;
summary: string | null;
};
export type MemorySourceEvidenceRef = {
session_id: string | null;
segment_id: string | null;
entry_range: [number, number] | null;
evidence_id: string | null;
origin?: MemoryEvidenceOrigin | null;
evidence_kind: string | null;
label: string | null;
summary: string | null;
};
export type MemoryStagingRecord = {
schema_version: number;
id: string;
extract_run_id: string;
source: MemorySourceRef;
kind: MemoryCandidateKind;
claim: string;
why_useful: string;
staleness: string | null;
evidence: Array<MemoryStagingEvidence>;
source_refs: Array<MemorySourceEvidenceRef>;
};
export type MemoryStagingEntry = {
id: string;
byte_len: number;
record: MemoryStagingRecord;
};
export type MemoryStagingListResponse = {
limit: number;
returned_count: number;
total_valid_count: number;
invalid_count: number;
truncated: boolean;
order: string;
record_authority: string;
items: Array<MemoryStagingEntry>;
diagnostics: Array<Diagnostic>;
};
+1 -1
View File
@@ -178,7 +178,7 @@ export type SubscriptionWorkdirId = string;
export type SubscriptionWorkerIds = Array<SubscriptionWorkerId>;
export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped" | "cancelled";
export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped";
export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" };
@@ -0,0 +1,88 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts
export const SKILL_API_AUTHORITY = "workspace-config-skills-v1" as const;
export const SKILL_API_LIMITS = {
maxSafeInteger: 9007199254740991,
maxCatalogEntries: 500,
maxOverrides: 64,
maxDiagnostics: 100,
maxResources: 500,
maxAllowedTools: 100,
maxNameBytes: 128,
maxLabelBytes: 4096,
maxBodyBytes: 1048576,
maxPathBytes: 1024,
maxDigestBytes: 128,
maxResponseBytes: 2097152,
} as const;
export type SkillDiagnosticSeverity = "error" | "warning";
export type SkillDiagnostic = {
severity: SkillDiagnosticSeverity;
code: string;
message: string;
source?: string;
};
export type SkillSourceKind = "builtin" | "workspace";
export type SkillProvenance = {
kind: SkillSourceKind;
id: string;
virtual_path?: string;
revision?: number;
source_digest?: string;
tree_digest?: string;
};
export type SkillActivationStatus = "active" | "inactive";
export type SkillProjectionStatus = "valid" | "invalid";
export type SkillProjectionIdentity = {
config_revision: number;
tree_digest: string;
};
export type SkillResourceRef = {
kind: string;
name: string;
supported: boolean;
diagnostic?: string;
};
export type SkillCatalogEntry = {
name: string;
description: string;
activation_status: SkillActivationStatus;
projection_status: SkillProjectionStatus;
provenance: SkillProvenance;
overrides: Array<SkillProvenance>;
diagnostics: Array<SkillDiagnostic>;
};
export type SkillCatalogResponse = {
authority: string;
projection: SkillProjectionIdentity;
entries: Array<SkillCatalogEntry>;
diagnostics: Array<SkillDiagnostic>;
};
export type SkillDetailResponse = {
authority: string;
projection: SkillProjectionIdentity;
name: string;
description: string;
provenance: SkillProvenance;
overrides: Array<SkillProvenance>;
diagnostics: Array<SkillDiagnostic>;
activation_status: SkillActivationStatus;
projection_status: SkillProjectionStatus;
body: string;
allowed_tools: Array<string>;
allowed_tools_status: string;
resources: Array<SkillResourceRef>;
};
@@ -25,7 +25,9 @@ export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_un
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
export type MergeRequestRefDiagnostic = { code: string, message: string, };
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, ref_diagnostics?: Array<MergeRequestRefDiagnostic>, };
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
@@ -0,0 +1,185 @@
// Generated from workspace-api. Do not edit by hand.
// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts
import type { Segment } from "./protocol";
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = {
code: string;
severity: DiagnosticSeverity;
message: string;
};
export type WorkingDirectoryMaterializerKind =
| "runtime_git_cache"
| "local_git_worktree";
export type WorkingDirectoryStatusKind =
| "active"
| "cleanup_pending"
| "corrupted"
| "not_found"
| "unknown";
export type WorkingDirectoryCleanupTarget = {
kind: string;
working_directory_id: string;
repository_key: string;
};
export type RuntimeWorkingDirectoryCleanupTarget = {
kind: string;
working_directory_id: string;
repository_id: string;
};
export type RuntimeWorkingDirectorySummary = {
working_directory_id: string;
repository_id: string;
creation_selector?: string | null;
creation_ref?: string | null;
creation_tree?: string | null;
current_selector?: string | null;
current_ref?: string | null;
current_tree?: string | null;
observed_at_epoch_seconds?: number | null;
materializer_kind: WorkingDirectoryMaterializerKind;
cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null;
status: WorkingDirectoryStatusKind;
cleanliness?: string | null;
primary_worker_id?: string | null;
occupied_by?: WorkingDirectoryOccupancy | null;
};
export type WorkingDirectoryOccupancy = {
runtime_id: string;
worker_id: string;
display_name: string;
linked_at: string;
};
export type WorkingDirectorySummary = {
working_directory_id: string;
repository_key: string;
creation_selector?: string | null;
creation_ref?: string | null;
creation_tree?: string | null;
current_selector?: string | null;
current_ref?: string | null;
current_tree?: string | null;
observed_at_epoch_seconds?: number | null;
materializer_kind: WorkingDirectoryMaterializerKind;
cleanup_target?: WorkingDirectoryCleanupTarget | null;
status: WorkingDirectoryStatusKind;
cleanliness?: string | null;
primary_worker_id?: string | null;
occupied_by?: WorkingDirectoryOccupancy | null;
};
export type WorkerWorkspaceSummary = {
visibility: string;
identity: string;
workspace_id?: string | null;
};
export type WorkerImplementationSummary = {
kind: string;
display_hint: string;
};
export type WorkerCapabilitySummary = {
can_stop: boolean;
can_spawn_followup: boolean;
};
export type WorkerLaunchWorkerSummary = {
runtime_id: string;
worker_id: string;
host_id: string;
display_name: string;
label: string;
profile: string | null;
singleton_key: string | null;
tags: Array<string>;
workspace: WorkerWorkspaceSummary;
state: string;
last_seen_at: string | null;
pinned: boolean;
retention_state: string;
implementation: WorkerImplementationSummary;
capabilities: WorkerCapabilitySummary;
working_directory?: RuntimeWorkingDirectorySummary | null;
diagnostics: Array<Diagnostic>;
};
export type WorkerLaunchRuntimeOption = {
runtime_id: string;
display_name: string;
built_in: boolean;
worker_creation_available: boolean;
working_directory_required: boolean;
status: string;
diagnostics: Array<Diagnostic>;
};
export type WorkerLaunchProfileCandidate = {
id: string;
label: string;
description: string;
};
export type WorkingDirectoryRepositoryOption = {
repository_key: string;
default_selector?: string | null;
};
export type WorkerLaunchOptionsResponse = {
workspace_id: string;
runtimes: Array<WorkerLaunchRuntimeOption>;
default_profile: string | null;
profiles: Array<WorkerLaunchProfileCandidate>;
repositories: Array<WorkingDirectoryRepositoryOption>;
working_directories: Array<WorkingDirectorySummary>;
diagnostics: Array<Diagnostic>;
};
export type BrowserWorkerWorkingDirectorySelection = {
working_directory_id: string;
relative_cwd: string | null;
};
export type CreateWorkspaceWorkerTicketAssignmentRequest = {
ticket_id: string;
operation_id: string;
};
export type CreateWorkspaceWorkerRequest = {
runtime_id: string;
display_name: string;
profile: string | null;
ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null;
initial_submit: Array<Segment>;
working_directory: BrowserWorkerWorkingDirectorySelection | null;
/**
* Backend idempotency key used only for authenticated Worker-owned spawn/control.
*/
control_operation_id: string | null;
};
export type BrowserCreateWorkerResponse = {
workspace_id: string;
runtime_id: string;
worker_id: string;
console_href: string;
worker: WorkerLaunchWorkerSummary;
diagnostics: Array<Diagnostic>;
};
export type BrowserWorkspaceOrchestratorResponse = {
workspace_id: string;
online: boolean;
disposition: string;
worker?: WorkerLaunchWorkerSummary | null;
diagnostics: Array<Diagnostic>;
};
@@ -1,11 +1,13 @@
import {
loadWorkspaceSkillCatalog,
loadWorkspaceSkillDetail,
workspaceApiPath,
workspaceRoute,
workspaceSkillActivationPath,
workspaceSkillCatalogPath,
workspaceSkillDetailPath,
} from "./http.ts";
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
@@ -89,11 +91,24 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
return Promise.resolve(
new Response(
JSON.stringify({
authority: "workspace-backend-skills-v0",
authority: "workspace-config-skills-v1",
projection: {
config_revision: 7,
tree_digest: "tree-digest",
},
entries: [{
name: "triage-errors",
description: "Use when triaging errors.",
provenance: { kind: "workspace", id: "workspace:triage-errors" },
activation_status: "active",
projection_status: "valid",
provenance: {
kind: "workspace",
id: "workspace:triage-errors",
virtual_path: "skills/triage-errors/SKILL.md",
revision: 7,
source_digest: "source-digest",
tree_digest: "tree-digest",
},
overrides: [],
diagnostics: [],
}],
@@ -110,3 +125,44 @@ Deno.test("loadWorkspaceSkillCatalog fetches lightweight catalog", async () => {
assertEquals(result.data?.entries[0].name, "triage-errors");
assertEquals(JSON.stringify(result.data).includes("SKILL.md body"), false);
});
Deno.test("Skill loaders redact and bound non-success response diagnostics", async () => {
const secret = "SENSITIVE-SKILL-BODY-CONTENT".repeat(300);
const result = await loadWorkspaceSkillCatalog(
(() =>
Promise.resolve(new Response(secret, { status: 500 }))) as typeof fetch,
"ws-1",
);
assertEquals(result.data, null);
assertEquals(result.error, "Skill API request failed with HTTP 500");
assert(
!result.error?.includes(secret.slice(0, 64)),
"Skill API diagnostic must not expose response body content",
);
assert(
(result.error?.length ?? 0) <= 256,
"Skill API diagnostic must remain bounded",
);
});
Deno.test("Skill loaders stop reading success responses above the wire byte limit", async () => {
const oversized = `{"body":"${
"x".repeat(SKILL_API_LIMITS.maxResponseBytes + 1)
}"}`;
const result = await loadWorkspaceSkillDetail(
(() =>
Promise.resolve(
new Response(oversized, { status: 200 }),
)) as typeof fetch,
"ws-1",
"release",
);
assertEquals(result.data, null);
assertEquals(result.error, "Skill API response exceeds its byte limit");
assert(
!result.error?.includes(oversized.slice(0, 64)),
"Skill API diagnostic must not expose oversized response content",
);
});
+89 -46
View File
@@ -1,58 +1,32 @@
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts";
import type {
SkillCatalogResponse,
SkillDetailResponse,
} from "$lib/generated/skill-api.ts";
import {
parseSkillCatalogResponse,
parseSkillDetailResponse,
SkillApiContractError,
} from "$lib/workspace/skills/api.ts";
export type ApiResult<T> = {
data: T | null;
error: string | null;
};
export type SkillDiagnosticSeverity = "error" | "warning";
export type { SkillCatalogResponse, SkillDetailResponse };
export type SkillDiagnostic = {
severity: SkillDiagnosticSeverity;
code: string;
message: string;
source?: string;
type JsonLoadPolicy = {
diagnosticLabel: string;
maxResponseBytes: number;
};
export type SkillProvenance = {
kind: "builtin" | "workspace";
id: string;
virtual_path?: string;
revision?: number;
source_digest?: string;
tree_digest?: string;
const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
diagnosticLabel: "Skill API",
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
};
export type SkillCatalogEntry = {
name: string;
description: string;
provenance: SkillProvenance;
overrides: SkillProvenance[];
diagnostics: SkillDiagnostic[];
};
export type SkillCatalogResponse = {
authority: string;
entries: SkillCatalogEntry[];
diagnostics: SkillDiagnostic[];
};
export type SkillResourceRef = {
kind: string;
name: string;
supported: boolean;
diagnostic?: string;
};
export type SkillDetailResponse = {
name: string;
description: string;
provenance: SkillProvenance;
overrides: SkillProvenance[];
diagnostics: SkillDiagnostic[];
body: string;
allowed_tools: string[];
allowed_tools_status: string;
resources: SkillResourceRef[];
};
class ResponseByteLimitError extends Error {}
function normalizePath(path: string): string {
if (!path || path === "/") return "";
@@ -95,6 +69,9 @@ export async function loadWorkspaceSkillCatalog(
return loadJson<SkillCatalogResponse>(
fetchFn,
workspaceSkillCatalogPath(workspaceId),
undefined,
parseSkillCatalogResponse,
SKILL_API_LOAD_POLICY,
);
}
@@ -106,6 +83,9 @@ export async function loadWorkspaceSkillDetail(
return loadJson<SkillDetailResponse>(
fetchFn,
workspaceSkillDetailPath(workspaceId, name),
undefined,
parseSkillDetailResponse,
SKILL_API_LOAD_POLICY,
);
}
@@ -114,19 +94,38 @@ export async function loadJson<T>(
path: string,
init?: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
policy?: JsonLoadPolicy,
): Promise<ApiResult<T>> {
try {
const response = await fetchFn(path, init);
if (!response.ok) {
if (policy) {
await response.body?.cancel();
return {
data: null,
error:
`${policy.diagnosticLabel} request failed with HTTP ${response.status}`,
};
}
const text = await response.text();
return {
data: null,
error: text || `${path} request failed (${response.status})`,
};
}
const payload: unknown = await response.json();
const payload: unknown = policy
? await readBoundedJson(response, policy.maxResponseBytes)
: await response.json();
return { data: parse(payload), error: null };
} catch (error) {
if (policy) {
const diagnostic = error instanceof SkillApiContractError
? error.message
: error instanceof ResponseByteLimitError
? `${policy.diagnosticLabel} response exceeds its byte limit`
: `${policy.diagnosticLabel} response is invalid`;
return { data: null, error: diagnostic.slice(0, 256) };
}
return {
data: null,
error: error instanceof Error ? error.message : `${path} request failed`,
@@ -134,6 +133,50 @@ export async function loadJson<T>(
}
}
async function readBoundedJson(
response: Response,
maxBytes: number,
): Promise<unknown> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsedLength = Number(contentLength);
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
await response.body?.cancel();
throw new ResponseByteLimitError();
}
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("response body is unavailable");
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new ResponseByteLimitError();
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return JSON.parse(text) as unknown;
}
async function requireJson<T>(response: Response, path: string): Promise<T> {
if (!response.ok) {
const text = await response.text();

Some files were not shown because too many files have changed in this diff Show More