Compare commits
11
Commits
f84377b2fe
...
8c85b93e7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c85b93e7d | ||
|
|
4017992c7d | ||
|
|
9dc6d8f144 | ||
|
|
779ced82b1 | ||
|
|
30e4985f9a | ||
|
|
1a1e3c286f | ||
|
|
71c906e04d | ||
|
|
e6bfb27fa9 | ||
|
|
2a3ece0364 | ||
|
|
61d174b174 | ||
|
|
c4814115de |
Generated
+2
@@ -6142,6 +6142,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
@@ -6215,6 +6216,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tui",
|
||||
"worker",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -9,7 +9,7 @@ protocol = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod spawn;
|
||||
pub mod target;
|
||||
pub mod ticket_role;
|
||||
mod worker_client;
|
||||
mod workspace_product;
|
||||
|
||||
pub use backend_auth::{
|
||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||
@@ -36,9 +37,9 @@ pub use backend_workspace::{
|
||||
};
|
||||
pub use runtime_command::WorkerRuntimeCommand;
|
||||
pub use target::{
|
||||
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
||||
WorkerSpawn,
|
||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
||||
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
||||
WorkerResume, WorkerSpawn,
|
||||
};
|
||||
|
||||
pub use spawn::{
|
||||
@@ -52,3 +53,5 @@ pub use ticket_role::{
|
||||
plan_ticket_role_launch_with_config,
|
||||
};
|
||||
pub use worker_client::WorkerClient;
|
||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||
pub use workspace_product::BackendWorkspaceProductClient;
|
||||
|
||||
+118
-4
@@ -8,6 +8,24 @@ pub enum TargetKind {
|
||||
Backend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedTarget {
|
||||
Local,
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolvedTarget {
|
||||
pub fn kind(&self) -> TargetKind {
|
||||
match self {
|
||||
Self::Local => TargetKind::Local,
|
||||
Self::Backend { .. } => TargetKind::Backend,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -104,8 +122,14 @@ pub struct WorkerResume {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Dashboard {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub enum Dashboard {
|
||||
Local {
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
},
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -156,6 +180,13 @@ impl std::error::Error for TargetError {}
|
||||
pub trait Target: fmt::Debug + Send + Sync {
|
||||
fn kind(&self) -> TargetKind;
|
||||
|
||||
/// Resolve the target once for Workspace product-state operations.
|
||||
///
|
||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
||||
/// this value instead of rediscovering Backend/local authority from cwd or
|
||||
/// process configuration after command dispatch.
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||
@@ -177,6 +208,10 @@ impl Target for LocalTarget {
|
||||
TargetKind::Local
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||
Ok(ResolvedTarget::Local)
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Ok(WorkerSpawn {
|
||||
runtime_command: self.runtime_command()?,
|
||||
@@ -196,7 +231,7 @@ impl Target for LocalTarget {
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Ok(Dashboard {
|
||||
Ok(Dashboard::Local {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
}
|
||||
@@ -231,6 +266,19 @@ impl Target for BackendTarget {
|
||||
TargetKind::Backend
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
||||
TargetError::invalid(
|
||||
self.kind(),
|
||||
"workspace selection is required for Backend product-state operations",
|
||||
)
|
||||
})?;
|
||||
Ok(ResolvedTarget::Backend {
|
||||
base_url: self.base_url.clone(),
|
||||
workspace_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||
}
|
||||
@@ -247,7 +295,16 @@ impl Target for BackendTarget {
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Err(TargetError::unsupported("Dashboard", self.kind()))
|
||||
match self.resolve()? {
|
||||
ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} => Ok(Dashboard::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
}),
|
||||
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
@@ -287,6 +344,63 @@ impl Target for BackendTarget {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backend_target_resolves_workspace_scoped_product_state_authority() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||
|
||||
assert_eq!(
|
||||
target.resolve().unwrap(),
|
||||
ResolvedTarget::Backend {
|
||||
base_url: "http://127.0.0.1:8787".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_product_state_resolution_without_workspace() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
|
||||
assert!(
|
||||
target
|
||||
.resolve()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("workspace selection is required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_resolves_local_product_state_authority() {
|
||||
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_builds_workspace_scoped_dashboard() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||
|
||||
assert_eq!(
|
||||
target.dashboard().unwrap(),
|
||||
Dashboard::Backend {
|
||||
base_url: "http://127.0.0.1:8787".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_dashboard_without_workspace_selection() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
|
||||
assert!(
|
||||
target
|
||||
.dashboard()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("workspace selection is required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_builds_worker_list() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||
|
||||
@@ -743,6 +743,7 @@ mod tests {
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
internal_workers: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit,
|
||||
TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
||||
};
|
||||
use workspace_api::{
|
||||
ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest,
|
||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
};
|
||||
|
||||
use crate::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,
|
||||
can_spawn_worker: 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.
|
||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||
/// failed requests against repository-local state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendWorkspaceProductClient {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
impl BackendWorkspaceProductClient {
|
||||
pub fn new(
|
||||
base_url: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
) -> Result<Self, BackendWorkspaceClientError> {
|
||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
||||
if base_url.is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend base URL must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend Workspace identity must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
workspace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
&self.workspace_id
|
||||
}
|
||||
|
||||
pub fn list_tickets(
|
||||
&self,
|
||||
query: &TicketListQuery,
|
||||
) -> Result<Vec<TicketSummary>, BackendWorkspaceClientError> {
|
||||
let state = ticket_list_state_query(query);
|
||||
self.get_json(&format!("/tickets/search?state={state}"))
|
||||
}
|
||||
|
||||
pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result<Ticket, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!(
|
||||
"/tickets/{}/record",
|
||||
encode_path_segment(&ticket_reference(id))
|
||||
))
|
||||
}
|
||||
|
||||
pub fn create_ticket(
|
||||
&self,
|
||||
input: &NewTicket,
|
||||
) -> Result<TicketRef, BackendWorkspaceClientError> {
|
||||
self.send_json(Method::POST, "/tickets", Some(input))
|
||||
}
|
||||
|
||||
pub fn add_ticket_event(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
event: &NewTicketEvent,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/thread-events",
|
||||
encode_path_segment(&ticket_reference(id))
|
||||
),
|
||||
Some(event),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow-state",
|
||||
encode_path_segment(&ticket_reference(id))
|
||||
),
|
||||
Some(change),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn close_ticket(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow/close",
|
||||
encode_path_segment(&ticket_reference(id))
|
||||
),
|
||||
Some(resolution),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn add_ticket_relation(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
relation: &NewTicketRelation,
|
||||
) -> Result<TicketRelation, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/relations",
|
||||
encode_path_segment(&ticket_reference(id))
|
||||
),
|
||||
Some(relation),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<&TicketIdOrSlug>,
|
||||
kind: Option<TicketRelationKind>,
|
||||
) -> Result<Vec<TicketRelation>, BackendWorkspaceClientError> {
|
||||
#[derive(Serialize)]
|
||||
struct Query<'a> {
|
||||
ticket: Option<&'a TicketIdOrSlug>,
|
||||
kind: Option<TicketRelationKind>,
|
||||
}
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
TICKET_RELATIONS_QUERY_PATH,
|
||||
Some(&Query { ticket, kind }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn ticket_doctor(&self) -> Result<TicketDoctorReport, BackendWorkspaceClientError> {
|
||||
self.get_json("/tickets/doctor")
|
||||
}
|
||||
|
||||
pub fn list_objectives(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<ListResponse<ObjectiveSummary>, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!("/objectives?limit={limit}"))
|
||||
}
|
||||
|
||||
pub fn show_objective(&self, id: &str) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!("/objectives/{}", encode_path_segment(id)))
|
||||
}
|
||||
|
||||
pub fn create_objective(
|
||||
&self,
|
||||
input: &ObjectiveCreateRequest,
|
||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(Method::POST, "/objectives", Some(input))
|
||||
}
|
||||
|
||||
pub fn edit_objective(
|
||||
&self,
|
||||
id: &str,
|
||||
input: &ObjectiveEditRequest,
|
||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::PATCH,
|
||||
&format!("/objectives/{}", encode_path_segment(id)),
|
||||
Some(input),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_objective_state(
|
||||
&self,
|
||||
id: &str,
|
||||
input: &ObjectiveStateRequest,
|
||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
&format!("/objectives/{}/state", encode_path_segment(id)),
|
||||
Some(input),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn link_objective_ticket(
|
||||
&self,
|
||||
id: &str,
|
||||
input: &ObjectiveLinkTicketRequest,
|
||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
&format!("/objectives/{}/ticket-links", encode_path_segment(id)),
|
||||
Some(input),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unlink_objective_ticket(
|
||||
&self,
|
||||
id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||
self.send_json::<(), _>(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/objectives/{}/ticket-links/{}",
|
||||
encode_path_segment(id),
|
||||
encode_path_segment(ticket_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn launch_ticket_intake(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
) -> Result<String, BackendWorkspaceClientError> {
|
||||
let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?;
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
.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}.")
|
||||
}]
|
||||
})),
|
||||
)?;
|
||||
Ok(format!(
|
||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
||||
response.runtime_id, response.worker_id
|
||||
))
|
||||
}
|
||||
|
||||
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
||||
let response: BackendWorkspaceOrchestratorResponse =
|
||||
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
||||
let worker = response.worker.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend accepted the Orchestrator request without returning a Worker".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(format!(
|
||||
"Workspace Orchestrator {} at {}/{}",
|
||||
response.disposition, worker.runtime_id, worker.worker_id
|
||||
))
|
||||
}
|
||||
|
||||
pub fn default_product_list_limit() -> usize {
|
||||
DEFAULT_PRODUCT_LIST_LIMIT
|
||||
}
|
||||
|
||||
fn get_json<R: DeserializeOwned>(&self, path: &str) -> Result<R, BackendWorkspaceClientError> {
|
||||
self.send_json::<(), R>(Method::GET, path, None)
|
||||
}
|
||||
|
||||
fn send_json<B: Serialize + ?Sized, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<R, BackendWorkspaceClientError> {
|
||||
let response = self.request(method, path, body)?.send()?;
|
||||
let response = ensure_success(response)?;
|
||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||
}
|
||||
|
||||
fn send_unit<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||
let client = reqwest::blocking::Client::builder().build()?;
|
||||
let url = format!(
|
||||
"{}/api/w/{}/{}",
|
||||
self.base_url,
|
||||
encode_path_segment(&self.workspace_id),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
let request = client.request(method, url);
|
||||
Ok(match body {
|
||||
Some(body) => request.json(body),
|
||||
None => request,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketBackend for BackendWorkspaceProductClient {
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct Request<'a> {
|
||||
from: &'a str,
|
||||
}
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
"/tickets/default-intake-ready-body",
|
||||
Some(&Request { from }),
|
||||
)
|
||||
.unwrap_or_else(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> {
|
||||
self.list_tickets(&filter).map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn show(&self, id: TicketIdOrSlug) -> ticket::Result<Ticket> {
|
||||
self.show_ticket(&id).map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn create(&self, input: NewTicket) -> ticket::Result<TicketRef> {
|
||||
self.create_ticket(&input).map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result<Ticket> {
|
||||
self.send_json(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/tickets/{}/item",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&edit),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result<TicketDependencyCheck> {
|
||||
self.get_json(&format!(
|
||||
"/tickets/{}/dependency-check",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
))
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()> {
|
||||
self.add_ticket_event(&id, &event)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_state_changed(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/state-changes",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&change),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_intake_summary(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
) -> ticket::Result<()> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/intake-summaries",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&summary),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_state_field(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.send_unit(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/state-fields/{}",
|
||||
encode_path_segment(&ticket_reference(&id)),
|
||||
encode_path_segment(field)
|
||||
),
|
||||
Some(&change),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_workflow_state(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.set_ticket_workflow_state(&id, &change)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow/mark-ready",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&request),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> {
|
||||
self.send_unit::<()>(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/workflow/queue",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
None,
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
||||
self.close_ticket(&id, &resolution)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
relation: NewTicketRelation,
|
||||
) -> ticket::Result<TicketRelation> {
|
||||
BackendWorkspaceProductClient::add_ticket_relation(self, &id, &relation)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn remove_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
kind: TicketRelationKind,
|
||||
target: TicketIdOrSlug,
|
||||
) -> ticket::Result<TicketRelation> {
|
||||
#[derive(Serialize)]
|
||||
struct Request {
|
||||
kind: TicketRelationKind,
|
||||
target: String,
|
||||
}
|
||||
self.send_json(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/tickets/{}/relations",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&Request {
|
||||
kind,
|
||||
target: ticket_reference(&target),
|
||||
}),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn query_ticket_relations(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<TicketRelationKind>,
|
||||
) -> ticket::Result<Vec<TicketRelation>> {
|
||||
BackendWorkspaceProductClient::query_ticket_relations(self, ticket.as_ref(), kind)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<TicketRelationView> {
|
||||
self.get_json(&format!(
|
||||
"/tickets/{}/relation-view",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
))
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_orchestration_plan_record(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
record: NewOrchestrationPlanRecord,
|
||||
) -> ticket::Result<OrchestrationPlanRecord> {
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/tickets/{}/orchestration-plans",
|
||||
encode_path_segment(&ticket_reference(&id))
|
||||
),
|
||||
Some(&record),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn query_orchestration_plan_records(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<OrchestrationPlanKind>,
|
||||
) -> ticket::Result<Vec<OrchestrationPlanRecord>> {
|
||||
#[derive(Serialize)]
|
||||
struct Query {
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<OrchestrationPlanKind>,
|
||||
}
|
||||
self.send_json(
|
||||
Method::POST,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
Some(&Query { ticket, kind }),
|
||||
)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn doctor(&self) -> ticket::Result<TicketDoctorReport> {
|
||||
self.ticket_doctor().map_err(ticket_client_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||
}
|
||||
|
||||
fn ensure_success(
|
||||
response: reqwest::blocking::Response,
|
||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
let message = response
|
||||
.text()
|
||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||
}
|
||||
|
||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||
match id {
|
||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||
TicketIdOrSlug::Slug(slug) | TicketIdOrSlug::Query(slug) => slug.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_list_state_query(query: &TicketListQuery) -> String {
|
||||
match &query.state {
|
||||
TicketStateSelector::Active => "active".to_string(),
|
||||
TicketStateSelector::All => "all".to_string(),
|
||||
TicketStateSelector::States(states) => states
|
||||
.iter()
|
||||
.copied()
|
||||
.map(TicketListState::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut encoded, "%{byte:02X}").expect("writing to String cannot fail");
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn one_response_server(
|
||||
status: &str,
|
||||
body: &str,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let status = status.to_string();
|
||||
let body = body.to_string();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 8_192];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
(format!("http://{address}"), receiver, handle)
|
||||
}
|
||||
|
||||
fn response_sequence_server(
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 16_384];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
(format!("http://{address}"), receiver, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let response = client.list_objectives(1_000).unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let error = client
|
||||
.create_objective(&ObjectiveCreateRequest {
|
||||
title: "Objective".to_string(),
|
||||
body_md: "body".to_string(),
|
||||
state: "active".to_string(),
|
||||
linked_tickets: Vec::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("403"));
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("POST /api/w/workspace-a/objectives ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let relations = client
|
||||
.query_ticket_relations(
|
||||
Some(&TicketIdOrSlug::Query("T-1".to_string())),
|
||||
Some(TicketRelationKind::Related),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(relations.is_empty());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets/relations/search "));
|
||||
assert!(request.contains("\"ticket\":{\"Query\":\"T-1\"}"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||
|
||||
assert!(records.is_empty());
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("POST /api/w/workspace-a/tickets/orchestration-plans/search ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_intake_launch_uses_backend_options_and_workspace_worker_route() {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||
|
||||
assert!(status.contains("embedded/worker-1"));
|
||||
assert!(
|
||||
requests
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("GET /api/w/workspace-a/workers/launch-options ")
|
||||
);
|
||||
let create_request = requests.recv().unwrap();
|
||||
assert!(create_request.starts_with("POST /api/w/workspace-a/workers "));
|
||||
assert!(create_request.contains("\"profile\":\"builtin:intake\""));
|
||||
assert!(create_request.contains("Ticket T-1"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
|
||||
let status = client.start_workspace_orchestrator().unwrap();
|
||||
|
||||
assert!(status.contains("created at embedded/worker-2"));
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("POST /api/w/workspace-a/orchestrator ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_client_requires_workspace_identity() {
|
||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
||||
assert!(error.to_string().contains("Workspace identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_state_query_preserves_local_filter_semantics() {
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery::active()),
|
||||
"active"
|
||||
);
|
||||
assert_eq!(ticket_list_state_query(&TicketListQuery::all()), "all");
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery {
|
||||
state: TicketStateSelector::States(
|
||||
[TicketListState::Ready, TicketListState::InProgress]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
"ready,inprogress"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_and_objective_references_are_path_encoded() {
|
||||
assert_eq!(encode_path_segment("T-1/a"), "T-1%2Fa");
|
||||
}
|
||||
}
|
||||
@@ -748,6 +748,8 @@ impl MemoryConfig {
|
||||
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),
|
||||
|
||||
@@ -95,7 +95,3 @@ 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`].
|
||||
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
||||
|
||||
/// Default language used by memory extraction / consolidation workers for
|
||||
/// durable memory text. See [`crate::MemoryConfig::language`].
|
||||
pub const MEMORY_LANGUAGE: &str = "English";
|
||||
|
||||
@@ -449,6 +449,29 @@ pub struct WebFetchConfig {
|
||||
pub allow_private_addresses: Option<bool>,
|
||||
}
|
||||
|
||||
/// Maximum Unicode scalar values accepted in a normalized Workspace Memory language name.
|
||||
pub const MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS: usize = 64;
|
||||
|
||||
/// Return whether a Workspace Memory language is already normalized and safe to persist.
|
||||
pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
|
||||
!language.is_empty()
|
||||
&& language == language.trim()
|
||||
&& language.chars().count() <= MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS
|
||||
&& !language.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Immutable Workspace Memory settings bound into a Worker launch snapshot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMemorySettingsSnapshot {
|
||||
/// Workspace that owns the settings revision.
|
||||
pub workspace_id: String,
|
||||
/// Monotonic Workspace Memory settings revision.
|
||||
pub settings_revision: u64,
|
||||
/// Normalized language used for Memory extraction and consolidation output.
|
||||
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
|
||||
@@ -477,11 +500,14 @@ pub struct MemoryConfig {
|
||||
/// system-prompt section. `None` ⇒ enabled.
|
||||
#[serde(default)]
|
||||
pub inject_summary: Option<bool>,
|
||||
/// Language used by memory extraction / consolidation sub_worker for durable
|
||||
/// memory text. Free-form so workspaces can use names like
|
||||
/// `English`, `Japanese`, or locale tags. `None` ⇒
|
||||
/// [`defaults::MEMORY_LANGUAGE`].
|
||||
#[serde(default)]
|
||||
/// 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
|
||||
@@ -520,6 +546,24 @@ pub struct MemoryConfig {
|
||||
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 {
|
||||
@@ -1223,6 +1267,18 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_memory_language_validation_is_bounded_free_form_utf8() {
|
||||
assert!(is_normalized_workspace_memory_language("Français"));
|
||||
assert!(is_normalized_workspace_memory_language("日本語"));
|
||||
assert!(!is_normalized_workspace_memory_language(""));
|
||||
assert!(!is_normalized_workspace_memory_language(" English "));
|
||||
assert!(!is_normalized_workspace_memory_language("English\n"));
|
||||
assert!(!is_normalized_workspace_memory_language(
|
||||
&"x".repeat(MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS + 1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_section_with_language() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
|
||||
@@ -562,7 +562,7 @@ fn resolve_profile_value(
|
||||
mcp: profile.mcp,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
memory: profile.memory,
|
||||
memory: profile.memory.map(Into::into),
|
||||
skills: profile.skills,
|
||||
};
|
||||
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
@@ -582,6 +582,51 @@ 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 {
|
||||
@@ -612,7 +657,7 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
#[serde(default)]
|
||||
memory: Option<MemoryConfig>,
|
||||
memory: Option<ProfileMemoryConfig>,
|
||||
#[serde(default)]
|
||||
skills: Option<SkillsConfig>,
|
||||
}
|
||||
@@ -1334,6 +1379,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for (field, value) in [
|
||||
("workspace_id", serde_json::json!("workspace-a")),
|
||||
("settings_revision", serde_json::json!(2)),
|
||||
("language", serde_json::json!("Japanese")),
|
||||
] {
|
||||
let artifact = serde_json::json!({ "memory": { (field): value } });
|
||||
let error = resolve_profile_artifact_value(
|
||||
artifact,
|
||||
ProfileSource::Registry {
|
||||
source: ProfileRegistrySource::Builtin,
|
||||
name: "test".to_string(),
|
||||
path: None,
|
||||
provenance: None,
|
||||
},
|
||||
tmp.path(),
|
||||
"test-worker",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("unknown field"),
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_can_manage_workdirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -177,6 +177,13 @@ impl OperationCounts {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemorySettingsAudit {
|
||||
pub workspace_id: String,
|
||||
pub settings_revision: u64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerLifecycleAudit {
|
||||
pub run_id: Uuid,
|
||||
@@ -185,6 +192,8 @@ pub struct WorkerLifecycleAudit {
|
||||
pub trigger: AuditTrigger,
|
||||
pub reason: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_settings: Option<MemorySettingsAudit>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<ModelAudit>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<UsageAudit>,
|
||||
@@ -405,6 +414,7 @@ mod tests {
|
||||
status: WorkerLifecycleStatus::Started,
|
||||
trigger: AuditTrigger::TokenThreshold,
|
||||
reason: "tokens_threshold_reached".to_string(),
|
||||
memory_settings: None,
|
||||
model: None,
|
||||
usage: None,
|
||||
extract: None,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{BackendWorkspaceProductClient, ObjectiveSummary};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
use ticket::{TicketListQuery, TicketSummary};
|
||||
|
||||
use crate::console::{enter_dashboard_fullscreen, leave_dashboard_fullscreen};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Tickets,
|
||||
Objectives,
|
||||
}
|
||||
|
||||
struct BackendDashboard {
|
||||
workspace_id: String,
|
||||
tickets: Vec<TicketSummary>,
|
||||
objectives: Vec<ObjectiveSummary>,
|
||||
focus: Focus,
|
||||
selected_ticket: usize,
|
||||
selected_objective: usize,
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub async fn launch(
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = BackendWorkspaceProductClient::new(base_url, workspace_id.clone())?;
|
||||
let (tickets, objectives) = load(&client).await?;
|
||||
let mut app = BackendDashboard {
|
||||
workspace_id,
|
||||
tickets,
|
||||
objectives,
|
||||
focus: Focus::Tickets,
|
||||
selected_ticket: 0,
|
||||
selected_objective: 0,
|
||||
status: "Backend Ticket and Objective authority selected".to_string(),
|
||||
};
|
||||
|
||||
let mut terminal = enter_dashboard_fullscreen()?;
|
||||
let result = run_loop(&mut terminal, &mut app, client).await;
|
||||
let restore_result = leave_dashboard_fullscreen(&mut terminal);
|
||||
result?;
|
||||
restore_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(
|
||||
client: &BackendWorkspaceProductClient,
|
||||
) -> Result<(Vec<TicketSummary>, Vec<ObjectiveSummary>), Box<dyn std::error::Error>> {
|
||||
let client = client.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let tickets = client
|
||||
.list_tickets(&TicketListQuery::active())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let objectives = client
|
||||
.list_objectives(BackendWorkspaceProductClient::default_product_list_limit())
|
||||
.map_err(|error| error.to_string())?
|
||||
.items;
|
||||
Ok::<_, String>((tickets, objectives))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Backend dashboard loading task failed: {error}"))?
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut BackendDashboard,
|
||||
client: BackendWorkspaceProductClient,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, app))?;
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
continue;
|
||||
}
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Tab | KeyCode::BackTab => {
|
||||
app.focus = match app.focus {
|
||||
Focus::Tickets => Focus::Objectives,
|
||||
Focus::Objectives => Focus::Tickets,
|
||||
};
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
|
||||
KeyCode::Char('r') => match load(&client).await {
|
||||
Ok((tickets, objectives)) => {
|
||||
app.tickets = tickets;
|
||||
app.objectives = objectives;
|
||||
app.clamp_selection();
|
||||
app.status = "Reloaded from Backend authority".to_string();
|
||||
}
|
||||
Err(error) => app.status = format!("Backend reload failed: {error}"),
|
||||
},
|
||||
KeyCode::Char('i') if app.focus == Focus::Tickets => {
|
||||
let Some(ticket_id) = app.selected_ticket_id() else {
|
||||
app.status = "Select a Ticket before launching Intake".to_string();
|
||||
continue;
|
||||
};
|
||||
let client = client.clone();
|
||||
match tokio::task::spawn_blocking(move || client.launch_ticket_intake(&ticket_id))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(status)) => app.status = status,
|
||||
Ok(Err(error)) => app.status = format!("Backend Intake launch failed: {error}"),
|
||||
Err(error) => {
|
||||
app.status = format!("Backend Intake launch task failed: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
let client = client.clone();
|
||||
match tokio::task::spawn_blocking(move || client.start_workspace_orchestrator())
|
||||
.await
|
||||
{
|
||||
Ok(Ok(status)) => app.status = status,
|
||||
Ok(Err(error)) => {
|
||||
app.status = format!("Backend Orchestrator launch failed: {error}")
|
||||
}
|
||||
Err(error) => {
|
||||
app.status = format!("Backend Orchestrator launch task failed: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendDashboard {
|
||||
fn select_next(&mut self) {
|
||||
match self.focus {
|
||||
Focus::Tickets if !self.tickets.is_empty() => {
|
||||
self.selected_ticket = (self.selected_ticket + 1).min(self.tickets.len() - 1);
|
||||
}
|
||||
Focus::Objectives if !self.objectives.is_empty() => {
|
||||
self.selected_objective =
|
||||
(self.selected_objective + 1).min(self.objectives.len() - 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_previous(&mut self) {
|
||||
match self.focus {
|
||||
Focus::Tickets => self.selected_ticket = self.selected_ticket.saturating_sub(1),
|
||||
Focus::Objectives => {
|
||||
self.selected_objective = self.selected_objective.saturating_sub(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_ticket_id(&self) -> Option<String> {
|
||||
self.tickets
|
||||
.get(self.selected_ticket)
|
||||
.map(|ticket| ticket.id.clone())
|
||||
}
|
||||
|
||||
fn clamp_selection(&mut self) {
|
||||
self.selected_ticket = self
|
||||
.selected_ticket
|
||||
.min(self.tickets.len().saturating_sub(1));
|
||||
self.selected_objective = self
|
||||
.selected_objective
|
||||
.min(self.objectives.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(frame: &mut Frame<'_>, app: &BackendDashboard) {
|
||||
let areas = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(frame.area());
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Workspace {} · Backend product state",
|
||||
app.workspace_id
|
||||
))
|
||||
.block(Block::default().borders(Borders::ALL).title("Panel")),
|
||||
areas[0],
|
||||
);
|
||||
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
|
||||
.split(areas[1]);
|
||||
draw_tickets(frame, columns[0], app);
|
||||
draw_objectives(frame, columns[1], app);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from("Tab switch · j/k move · r reload · i Intake · o Orchestrator · q quit"),
|
||||
Line::from(app.status.as_str()),
|
||||
])
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(Block::default().borders(Borders::ALL).title("Status")),
|
||||
areas[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_tickets(frame: &mut Frame<'_>, area: Rect, app: &BackendDashboard) {
|
||||
let items = app.tickets.iter().enumerate().map(|(index, ticket)| {
|
||||
let marker = if index == app.selected_ticket {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"{marker} {} [{}] {}",
|
||||
ticket.resource_key.as_deref().unwrap_or(&ticket.id),
|
||||
ticket.workflow_state.as_str(),
|
||||
ticket.title
|
||||
))
|
||||
});
|
||||
let style = focus_style(app.focus == Focus::Tickets);
|
||||
frame.render_widget(
|
||||
List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(style)
|
||||
.title(format!("Tickets ({})", app.tickets.len())),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_objectives(frame: &mut Frame<'_>, area: Rect, app: &BackendDashboard) {
|
||||
let items = app.objectives.iter().enumerate().map(|(index, objective)| {
|
||||
let marker = if index == app.selected_objective {
|
||||
">"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"{marker} {} [{}] {}",
|
||||
objective.resource_key, objective.state, objective.title
|
||||
))
|
||||
});
|
||||
let style = focus_style(app.focus == Focus::Objectives);
|
||||
frame.render_widget(
|
||||
List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(style)
|
||||
.title(format!("Objectives ({})", app.objectives.len())),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backend_dashboard_navigation_is_bounded() {
|
||||
let mut app = BackendDashboard {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
tickets: Vec::new(),
|
||||
objectives: Vec::new(),
|
||||
focus: Focus::Tickets,
|
||||
selected_ticket: 0,
|
||||
selected_objective: 0,
|
||||
status: String::new(),
|
||||
};
|
||||
app.select_next();
|
||||
app.select_previous();
|
||||
assert_eq!(app.selected_ticket, 0);
|
||||
assert_eq!(app.selected_objective, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod backend_dashboard;
|
||||
mod backend_worker_picker;
|
||||
mod backend_workspace_picker;
|
||||
mod block;
|
||||
@@ -181,7 +182,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Panel { include_stopped } => match target.dashboard() {
|
||||
Ok(dashboard) => dashboard::launch(dashboard.runtime_command, include_stopped).await,
|
||||
Ok(client::Dashboard::Local { runtime_command }) => {
|
||||
dashboard::launch(runtime_command, include_stopped).await
|
||||
}
|
||||
Ok(client::Dashboard::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
}) => backend_dashboard::launch(base_url, workspace_id).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
};
|
||||
|
||||
+139
-17
@@ -22,7 +22,7 @@ use async_trait::async_trait;
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, Notify, broadcast, watch};
|
||||
use tokio::sync::{Mutex, broadcast, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
@@ -54,7 +54,7 @@ fn command_observed_at_ms() -> u64 {
|
||||
enum LocalCommand {
|
||||
Running {
|
||||
task: JoinHandle<Result<CommandOutput, WorkdirError>>,
|
||||
completion: Arc<Notify>,
|
||||
completion: watch::Receiver<bool>,
|
||||
cancel: watch::Sender<bool>,
|
||||
},
|
||||
Completed(CommandOutput),
|
||||
@@ -666,21 +666,19 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||
let handle = CommandHandle(format!("command-{id}"));
|
||||
let cwd = self.inner.cwd.clone();
|
||||
let completion = Arc::new(Notify::new());
|
||||
let task_completion = Arc::clone(&completion);
|
||||
let (completion_tx, completion) = watch::channel(false);
|
||||
let command_id = handle.0.clone();
|
||||
let telemetry = self.inner.command_telemetry.clone();
|
||||
let (cancel, cancel_rx) = watch::channel(false);
|
||||
let task = tokio::spawn(async move {
|
||||
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await;
|
||||
task_completion.notify_one();
|
||||
let _ = completion_tx.send(true);
|
||||
output
|
||||
});
|
||||
let mut commands = self.inner.commands.lock().await;
|
||||
if let Err(error) = self.ensure_open() {
|
||||
let _ = cancel.send(true);
|
||||
task.abort();
|
||||
completion.notify_one();
|
||||
return Err(error);
|
||||
}
|
||||
commands.insert(
|
||||
@@ -725,12 +723,14 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
return Err(WorkdirError::UnknownCommand(request.handle.0.clone()));
|
||||
};
|
||||
let completion = match command {
|
||||
LocalCommand::Running {
|
||||
task, completion, ..
|
||||
} if !task.is_finished() => Some(Arc::clone(completion)),
|
||||
LocalCommand::Running { completion, .. }
|
||||
if !*completion.borrow() && completion.has_changed().is_ok() =>
|
||||
{
|
||||
Some(completion.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(completion) = completion {
|
||||
if let Some(mut completion) = completion {
|
||||
if !request.wait {
|
||||
return Ok(CommandOutput {
|
||||
status: CommandStatus::Running,
|
||||
@@ -742,9 +742,21 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
});
|
||||
}
|
||||
drop(commands);
|
||||
completion.notified().await;
|
||||
let _ = completion.changed().await;
|
||||
continue;
|
||||
}
|
||||
if !request.wait
|
||||
&& matches!(command, LocalCommand::Running { task, .. } if !task.is_finished())
|
||||
{
|
||||
return Ok(CommandOutput {
|
||||
status: CommandStatus::Running,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
break commands
|
||||
.remove(&request.handle.0)
|
||||
.expect("command checked above");
|
||||
@@ -821,14 +833,9 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
};
|
||||
for command in commands {
|
||||
match command {
|
||||
LocalCommand::Running {
|
||||
task,
|
||||
completion,
|
||||
cancel,
|
||||
} => {
|
||||
LocalCommand::Running { task, cancel, .. } => {
|
||||
let _ = cancel.send(true);
|
||||
let _ = task.await;
|
||||
completion.notify_one();
|
||||
}
|
||||
LocalCommand::Completed(_) => {}
|
||||
}
|
||||
@@ -2048,6 +2055,121 @@ mod tests {
|
||||
assert!(decoder.pending.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_output_does_not_rewait_before_join_handle_finishes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let handle = CommandHandle("command-completion-race".into());
|
||||
let (completion_tx, completion) = watch::channel(false);
|
||||
let completion_observer = completion_tx.clone();
|
||||
let (cancel, _cancel_rx) = watch::channel(false);
|
||||
let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (completion_sent_tx, completion_sent_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let task = tokio::spawn(async move {
|
||||
start_rx.await.unwrap();
|
||||
completion_tx.send(true).unwrap();
|
||||
completion_sent_tx.send(()).unwrap();
|
||||
release_rx.await.unwrap();
|
||||
Ok(CommandOutput {
|
||||
status: CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "done".into(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
})
|
||||
});
|
||||
workdir.inner.commands.lock().await.insert(
|
||||
handle.0.clone(),
|
||||
LocalCommand::Running {
|
||||
task,
|
||||
completion,
|
||||
cancel,
|
||||
},
|
||||
);
|
||||
|
||||
let waiting_workdir = workdir.clone();
|
||||
let waiting_handle = handle.clone();
|
||||
let waiter = tokio::spawn(async move {
|
||||
WorkdirSession::command_output(
|
||||
&waiting_workdir,
|
||||
CommandOutputRequest {
|
||||
handle: waiting_handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
for _ in 0..100 {
|
||||
if completion_observer.receiver_count() >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert_eq!(
|
||||
completion_observer.receiver_count(),
|
||||
2,
|
||||
"waiter must subscribe before completion is published"
|
||||
);
|
||||
|
||||
start_tx.send(()).unwrap();
|
||||
completion_sent_rx.await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
!waiter.is_finished(),
|
||||
"command_output should await the task after observing completion state"
|
||||
);
|
||||
release_tx.send(()).unwrap();
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("command output must not wait for a second completion notification")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(output.status, CommandStatus::Completed);
|
||||
assert_eq!(output.content, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_output_does_not_wait_forever_when_completion_sender_drops() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let handle = CommandHandle("command-completion-drop".into());
|
||||
let (completion_tx, completion) = watch::channel(false);
|
||||
let (cancel, _cancel_rx) = watch::channel(false);
|
||||
let task: JoinHandle<Result<CommandOutput, WorkdirError>> = tokio::spawn(async move {
|
||||
drop(completion_tx);
|
||||
panic!("simulated command task panic");
|
||||
});
|
||||
workdir.inner.commands.lock().await.insert(
|
||||
handle.0.clone(),
|
||||
LocalCommand::Running {
|
||||
task,
|
||||
completion,
|
||||
cancel,
|
||||
},
|
||||
);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
WorkdirSession::command_output(
|
||||
&workdir,
|
||||
CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("closed completion channel must wake the waiter");
|
||||
assert!(matches!(result, Err(WorkdirError::Unavailable(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_streams_bounded_command_lifecycle_and_distinct_output() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -174,6 +174,9 @@ pub struct CreateWorkerRequest {
|
||||
pub worker_observation_grants: Vec<RuntimeWorkerRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_api: Option<WorkspaceApiRef>,
|
||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
}
|
||||
|
||||
/// Worker lifecycle status for the in-memory embedded runtime.
|
||||
|
||||
@@ -1882,6 +1882,11 @@ mod tests {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
base_url: format!("https://workspace.example/{workspace_id}"),
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
@@ -2193,6 +2198,11 @@ mod tests {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2828,6 +2838,11 @@ mod ws_tests {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2794,6 +2794,27 @@ fn validate_create_workspace_scope(
|
||||
)));
|
||||
}
|
||||
}
|
||||
let snapshot = request.memory_settings.as_ref().ok_or_else(|| {
|
||||
RuntimeError::InvalidRequest(
|
||||
"Workspace-scoped Worker create requires a bound Memory settings snapshot".to_string(),
|
||||
)
|
||||
})?;
|
||||
if snapshot.workspace_id != workspace_id {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"Memory settings workspace_id {} does not match Runtime auth workspace_id {workspace_id}",
|
||||
snapshot.workspace_id
|
||||
)));
|
||||
}
|
||||
if snapshot.settings_revision == 0 {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Memory settings revision must be at least 1".to_string(),
|
||||
));
|
||||
}
|
||||
if !manifest::is_normalized_workspace_memory_language(&snapshot.language) {
|
||||
return Err(RuntimeError::InvalidRequest(
|
||||
"Memory settings language must be a normalized bounded UTF-8 value".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3086,6 +3107,11 @@ mod tests {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3095,9 +3121,39 @@ mod tests {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
base_url: format!("https://workspace.example/{workspace_id}"),
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_requires_matching_normalized_memory_settings_snapshot() {
|
||||
let mut request = scoped_task_request("memory-snapshot", "workspace-a");
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_ok());
|
||||
request.memory_settings.as_mut().unwrap().language = "Français".to_string();
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_ok());
|
||||
|
||||
request.memory_settings = None;
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
|
||||
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-b".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
|
||||
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
settings_revision: 2,
|
||||
language: " english ".to_string(),
|
||||
});
|
||||
assert!(validate_create_workspace_scope(&request, Some("workspace-a")).is_err());
|
||||
}
|
||||
|
||||
fn scope(workspace_id: &str, server_id: &str) -> RuntimeWorkspaceScope {
|
||||
RuntimeWorkspaceScope::new(workspace_id, server_id)
|
||||
}
|
||||
@@ -4484,7 +4540,7 @@ mod tests {
|
||||
let worker = runtime
|
||||
.create_worker_scoped(
|
||||
&RuntimeWorkspaceScope::new("workspace-a", "server"),
|
||||
task_request("legacy"),
|
||||
scoped_task_request("legacy", "workspace-a"),
|
||||
)
|
||||
.unwrap();
|
||||
drop(runtime);
|
||||
|
||||
@@ -642,6 +642,60 @@ fn runtime_local_workdir_session(
|
||||
))
|
||||
}
|
||||
|
||||
fn bind_workspace_memory_settings(
|
||||
manifest: &mut manifest::WorkerManifest,
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
let Some(snapshot) = request.memory_settings.as_ref() else {
|
||||
if request.workspace_api.is_some() {
|
||||
return Err(
|
||||
"Workspace Worker request is missing its bound Memory settings snapshot"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(workspace_api) = request.workspace_api.as_ref()
|
||||
&& snapshot.workspace_id != workspace_api.workspace_id
|
||||
{
|
||||
return Err(format!(
|
||||
"Memory settings workspace {} does not match Workspace API scope {}",
|
||||
snapshot.workspace_id, workspace_api.workspace_id
|
||||
));
|
||||
}
|
||||
manifest
|
||||
.memory
|
||||
.get_or_insert_with(manifest::MemoryConfig::default)
|
||||
.bind_workspace_settings(snapshot);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_worker_memory_settings(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
request: &CreateWorkerRequest,
|
||||
) -> Result<(), String> {
|
||||
let Some(expected) = request.memory_settings.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = manifest
|
||||
.memory
|
||||
.as_ref()
|
||||
.and_then(manifest::MemoryConfig::workspace_settings)
|
||||
.ok_or_else(|| {
|
||||
"Workspace Worker restored without its bound Memory settings snapshot".to_string()
|
||||
})?;
|
||||
if &actual != expected {
|
||||
return Err(format!(
|
||||
"Workspace Worker Memory settings snapshot mismatch: expected {} revision {}, restored {} revision {}",
|
||||
expected.workspace_id,
|
||||
expected.settings_revision,
|
||||
actual.workspace_id,
|
||||
actual.settings_revision
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
fn observe_workspace_prompt_projection(
|
||||
@@ -696,7 +750,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
let archive = self
|
||||
.resolve_profile_source_archive(&request.request.profile_source)
|
||||
.await?;
|
||||
let (manifest, mut loader) = {
|
||||
let (mut manifest, mut loader) = {
|
||||
let manifest = archive
|
||||
.resolve_profile(selector, &worker_root, &worker_name)
|
||||
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
|
||||
@@ -714,6 +768,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
)?
|
||||
}
|
||||
};
|
||||
bind_workspace_memory_settings(&mut manifest, &request.request)?;
|
||||
if let Some(bundle) = request.config_bundle.as_ref()
|
||||
&& let Some(resolution) =
|
||||
self.observe_bundle_prompt_projection(bundle, observation_workspace_id.as_deref())?
|
||||
@@ -750,6 +805,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
|
||||
validate_worker_memory_settings(worker.manifest(), &request.request)?;
|
||||
if let Some(binding) = request.working_directory.as_ref() {
|
||||
worker.bind_workdir_session(Some(runtime_local_workdir_session(
|
||||
&binding.working_directory.id,
|
||||
@@ -856,7 +912,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
self.embedded_worker_mutation_dispatcher.as_ref(),
|
||||
Some(self.prompt_projection_cache.clone()),
|
||||
);
|
||||
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
let (mut manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
bind_workspace_memory_settings(&mut manifest, &request.request)?;
|
||||
|
||||
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
|
||||
let session_dir = worker_aggregate_dir.join("session");
|
||||
@@ -924,6 +981,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
}
|
||||
Err(err) => return Err(format!("failed to restore Worker from metadata: {err}")),
|
||||
};
|
||||
validate_worker_memory_settings(worker.manifest(), &request.request)?;
|
||||
let flow_transition_enabled = worker.manifest().feature.flow.enabled;
|
||||
if let Some(binding) = request.working_directory.as_ref() {
|
||||
worker.bind_workdir_session(Some(runtime_local_workdir_session(
|
||||
@@ -2504,6 +2562,7 @@ mod tests {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2765,7 +2824,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(worker_allocation)]
|
||||
async fn restore_pending_workspace_worker_without_system_prompt_fails_closed() {
|
||||
async fn restore_legacy_workspace_worker_without_manifest_snapshot_requires_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let runtime_store_dir = root.path().join("runtime");
|
||||
let worker_ref = WorkerRef::new(crate::identity::WorkerId::from_legacy_u64(1));
|
||||
@@ -2774,32 +2833,6 @@ mod tests {
|
||||
.join(worker_ref.worker_id.to_string());
|
||||
let worker_name = ProfileRuntimeWorkerFactory::runtime_worker_name_for_ref(&worker_ref);
|
||||
let session_id = session_store::new_session_id();
|
||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||
r#"
|
||||
[worker]
|
||||
name = "{}"
|
||||
pwd = "{}"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
auth = {{ kind = "none" }}
|
||||
|
||||
[engine]
|
||||
max_tokens = 100
|
||||
|
||||
[feature.flow]
|
||||
enabled = true
|
||||
|
||||
[[scope.allow]]
|
||||
target = "{}"
|
||||
permission = "write"
|
||||
"#,
|
||||
worker_name,
|
||||
root.path().display(),
|
||||
root.path().display(),
|
||||
))
|
||||
.unwrap();
|
||||
WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name)
|
||||
.unwrap()
|
||||
.set_active(
|
||||
@@ -2807,7 +2840,7 @@ mod tests {
|
||||
Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
||||
session_id,
|
||||
)),
|
||||
Some(serde_json::to_value(&manifest).unwrap()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -2816,6 +2849,11 @@ mod tests {
|
||||
workspace_id: "workspace-restore".to_string(),
|
||||
base_url: "http://workspace.invalid".to_string(),
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-restore".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-restore").unwrap();
|
||||
let error = match ProfileRuntimeWorkerFactory::new(root.path())
|
||||
.with_runtime_store_dir(&runtime_store_dir)
|
||||
@@ -2835,10 +2873,10 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("pending Workspace Worker restore unexpectedly succeeded"),
|
||||
Ok(_) => panic!("legacy Workspace Worker restore unexpectedly succeeded"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.contains("requires operation-owned launch material"));
|
||||
assert!(error.contains("replacement Worker is required"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+261
-11
@@ -2163,6 +2163,33 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
let Some(template) = self.system_prompt_template.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let is_memory_consolidation = self.manifest.profile.as_ref().is_some_and(|snapshot| {
|
||||
matches!(
|
||||
&snapshot.source,
|
||||
manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::Builtin,
|
||||
name,
|
||||
..
|
||||
} if name == "memory-consolidation"
|
||||
)
|
||||
});
|
||||
if is_memory_consolidation {
|
||||
let memory_config = self.manifest.memory.as_ref().ok_or_else(|| {
|
||||
WorkerError::InvalidState(
|
||||
"Memory consolidation Worker has no Memory configuration".to_string(),
|
||||
)
|
||||
})?;
|
||||
let language = memory_language(memory_config)?;
|
||||
let rendered = self
|
||||
.prompts
|
||||
.load_full()
|
||||
.memory_consolidation_system(&language)?;
|
||||
self.engine
|
||||
.as_mut()
|
||||
.expect("worker present")
|
||||
.set_system_prompt(rendered);
|
||||
return Ok(());
|
||||
}
|
||||
let alerter = self.alerter.clone();
|
||||
let tool_names: Vec<String> = {
|
||||
let worker = self.engine.as_mut().expect("worker present");
|
||||
@@ -3750,6 +3777,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
memory::audit::AuditTrigger::TokenThreshold,
|
||||
Some(model_audit_from_manifest(model)),
|
||||
)
|
||||
.with_memory_settings(&memory_cfg)
|
||||
.emit(
|
||||
self.workspace_client(),
|
||||
self.event_tx.as_ref(),
|
||||
@@ -3780,6 +3808,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
memory::audit::AuditTrigger::TokenThreshold,
|
||||
Some(model_audit_from_manifest(model)),
|
||||
)
|
||||
.with_memory_settings(&memory_cfg)
|
||||
.emit(
|
||||
self.workspace_client(),
|
||||
self.event_tx.as_ref(),
|
||||
@@ -3845,7 +3874,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
memory::audit::AuditWorker::MemoryExtract,
|
||||
memory::audit::AuditTrigger::TokenThreshold,
|
||||
Some(model_audit_from_manifest(model)),
|
||||
);
|
||||
)
|
||||
.with_memory_settings(memory_cfg);
|
||||
let event_tx = self.event_tx.as_ref();
|
||||
|
||||
let pointer_snapshot = self
|
||||
@@ -3997,11 +4027,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let memory_language = memory_language(memory_cfg);
|
||||
let memory_language = memory_language(memory_cfg)?;
|
||||
let extract_system_prompt = match self
|
||||
.prompts
|
||||
.load_full()
|
||||
.memory_extract_system(memory_language)
|
||||
.memory_extract_system(&memory_language)
|
||||
{
|
||||
Ok(prompt) => prompt,
|
||||
Err(err) => {
|
||||
@@ -4191,6 +4221,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
memory::audit::AuditTrigger::StagingBacklog,
|
||||
Some(model_audit_from_manifest(model)),
|
||||
)
|
||||
.with_memory_settings(&memory_cfg)
|
||||
.emit(
|
||||
self.workspace_client(),
|
||||
self.event_tx.as_ref(),
|
||||
@@ -4232,6 +4263,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
memory::audit::AuditTrigger::StagingBacklog,
|
||||
Some(model_audit_from_manifest(model)),
|
||||
)
|
||||
.with_memory_settings(&memory_cfg)
|
||||
.emit(
|
||||
self.workspace_client(),
|
||||
self.event_tx.as_ref(),
|
||||
@@ -4312,6 +4344,7 @@ struct WorkerAuditBase {
|
||||
worker: memory::audit::AuditWorker,
|
||||
trigger: memory::audit::AuditTrigger,
|
||||
model: Option<memory::audit::ModelAudit>,
|
||||
memory_settings: Option<memory::audit::MemorySettingsAudit>,
|
||||
}
|
||||
|
||||
impl WorkerAuditBase {
|
||||
@@ -4325,9 +4358,22 @@ impl WorkerAuditBase {
|
||||
worker,
|
||||
trigger,
|
||||
model,
|
||||
memory_settings: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_memory_settings(mut self, memory_config: &manifest::MemoryConfig) -> Self {
|
||||
self.memory_settings =
|
||||
memory_config
|
||||
.workspace_settings()
|
||||
.map(|snapshot| memory::audit::MemorySettingsAudit {
|
||||
workspace_id: snapshot.workspace_id,
|
||||
settings_revision: snapshot.settings_revision,
|
||||
language: snapshot.language,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
async fn emit(
|
||||
&self,
|
||||
workspace_client: &dyn WorkspaceClient,
|
||||
@@ -4345,6 +4391,7 @@ impl WorkerAuditBase {
|
||||
status,
|
||||
trigger: self.trigger,
|
||||
reason: reason.clone(),
|
||||
memory_settings: self.memory_settings.clone(),
|
||||
model: self.model.clone(),
|
||||
usage,
|
||||
extract,
|
||||
@@ -4391,12 +4438,14 @@ fn is_idle_consolidation_skip_reason(reason: &str) -> bool {
|
||||
|| reason.starts_with("threshold_not_reached")
|
||||
}
|
||||
|
||||
fn memory_language(cfg: &manifest::MemoryConfig) -> &str {
|
||||
cfg.language
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|language| !language.is_empty())
|
||||
.unwrap_or(manifest::defaults::MEMORY_LANGUAGE)
|
||||
fn memory_language(cfg: &manifest::MemoryConfig) -> Result<String, WorkerError> {
|
||||
cfg.workspace_settings()
|
||||
.map(|snapshot| snapshot.language)
|
||||
.ok_or_else(|| {
|
||||
WorkerError::InvalidState(
|
||||
"Memory operation requires a bound Workspace Memory settings snapshot".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_language(cfg: &manifest::EngineManifest) -> &str {
|
||||
@@ -4453,6 +4502,7 @@ where
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
validate_workspace_memory_snapshot(&manifest.worker.name, &manifest, &workspace_context)?;
|
||||
let common = prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
&loader,
|
||||
@@ -4654,6 +4704,7 @@ where
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
validate_workspace_memory_snapshot(&manifest.worker.name, &manifest, &workspace_context)?;
|
||||
let common = prepare_worker_common_with_context(
|
||||
&manifest,
|
||||
&loader,
|
||||
@@ -4768,6 +4819,13 @@ where
|
||||
.ok_or_else(|| WorkerError::WorkerMetadataMissing {
|
||||
worker_name: worker_name.to_string(),
|
||||
})?;
|
||||
if workspace_context.workspace_id().is_some()
|
||||
&& metadata.resolved_manifest_snapshot.is_none()
|
||||
{
|
||||
return Err(WorkerError::WorkerMetadataManifestSnapshotMissing {
|
||||
worker_name: worker_name.to_string(),
|
||||
});
|
||||
}
|
||||
let active = metadata
|
||||
.active
|
||||
.ok_or_else(|| WorkerError::WorkerMetadataInactive {
|
||||
@@ -4817,6 +4875,13 @@ where
|
||||
.ok_or_else(|| WorkerError::WorkerMetadataMissing {
|
||||
worker_name: worker_name.to_string(),
|
||||
})?;
|
||||
if workspace_context.workspace_id().is_some()
|
||||
&& metadata.resolved_manifest_snapshot.is_none()
|
||||
{
|
||||
return Err(WorkerError::WorkerMetadataManifestSnapshotMissing {
|
||||
worker_name: worker_name.to_string(),
|
||||
});
|
||||
}
|
||||
let active = metadata
|
||||
.active
|
||||
.ok_or_else(|| WorkerError::WorkerMetadataInactive {
|
||||
@@ -5156,8 +5221,48 @@ fn worker_metadata_for_manifest(
|
||||
metadata
|
||||
}
|
||||
|
||||
fn validate_workspace_memory_snapshot(
|
||||
worker_name: &str,
|
||||
manifest: &WorkerManifest,
|
||||
workspace_context: &WorkerWorkspaceContext,
|
||||
) -> Result<(), WorkerError> {
|
||||
let Some(workspace_id) = workspace_context.workspace_id() else {
|
||||
return Ok(());
|
||||
};
|
||||
let snapshot = manifest
|
||||
.memory
|
||||
.as_ref()
|
||||
.and_then(manifest::MemoryConfig::workspace_settings)
|
||||
.ok_or_else(|| {
|
||||
WorkerError::InvalidState(format!(
|
||||
"Workspace Worker {worker_name} has no complete persisted Memory settings snapshot"
|
||||
))
|
||||
})?;
|
||||
if snapshot.workspace_id != workspace_id.as_str() {
|
||||
return Err(WorkerError::InvalidState(format!(
|
||||
"Workspace Worker {worker_name} Memory settings belong to {} instead of {}",
|
||||
snapshot.workspace_id,
|
||||
workspace_id.as_str()
|
||||
)));
|
||||
}
|
||||
if snapshot.settings_revision == 0
|
||||
|| !manifest::is_normalized_workspace_memory_language(&snapshot.language)
|
||||
{
|
||||
return Err(WorkerError::InvalidState(format!(
|
||||
"Workspace Worker {worker_name} has corrupt Memory settings snapshot metadata"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_persist_resolved_manifest_snapshot(manifest: &WorkerManifest) -> bool {
|
||||
manifest.profile.is_some() || manifest.plugins.has_resolved_plan()
|
||||
manifest.profile.is_some()
|
||||
|| manifest.plugins.has_resolved_plan()
|
||||
|| manifest
|
||||
.memory
|
||||
.as_ref()
|
||||
.and_then(manifest::MemoryConfig::workspace_settings)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn restore_manifest_from_worker_metadata_snapshot(
|
||||
@@ -5678,7 +5783,9 @@ pub enum WorkerError {
|
||||
session_id: SessionId,
|
||||
},
|
||||
|
||||
#[error("worker metadata for {worker_name} does not include a resolved manifest snapshot")]
|
||||
#[error(
|
||||
"worker metadata for {worker_name} does not include a trusted resolved manifest snapshot; a replacement Worker is required"
|
||||
)]
|
||||
WorkerMetadataManifestSnapshotMissing { worker_name: String },
|
||||
|
||||
#[error(
|
||||
@@ -6179,6 +6286,92 @@ permission = "write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_memory_settings_snapshot_is_persisted_and_scope_checked() {
|
||||
let mut manifest = WorkerManifest::from_toml(
|
||||
r#"
|
||||
[worker]
|
||||
name = "memory-snapshot"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "claude-sonnet-4-20250514"
|
||||
|
||||
[engine]
|
||||
instruction = "default"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/workspace"
|
||||
permission = "read"
|
||||
|
||||
[[delegation_scope.allow]]
|
||||
target = "/workspace"
|
||||
permission = "read"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
manifest.memory = Some(manifest::MemoryConfig::default());
|
||||
manifest.memory.as_mut().unwrap().bind_workspace_settings(
|
||||
&manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
settings_revision: 7,
|
||||
language: "Japanese".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let metadata = worker_metadata_for_manifest(&manifest, None, None, None);
|
||||
let restored: WorkerManifest = serde_json::from_value(
|
||||
metadata
|
||||
.resolved_manifest_snapshot
|
||||
.expect("Memory settings require a resolved manifest snapshot"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.memory.unwrap().workspace_settings(),
|
||||
Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
settings_revision: 7,
|
||||
language: "Japanese".to_string(),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
validate_workspace_memory_snapshot(
|
||||
"memory-snapshot",
|
||||
&manifest,
|
||||
&WorkerWorkspaceContext::unavailable(
|
||||
Some(WorkspaceId::new("workspace-a").unwrap()),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_workspace_memory_snapshot(
|
||||
"memory-snapshot",
|
||||
&manifest,
|
||||
&WorkerWorkspaceContext::unavailable(
|
||||
Some(WorkspaceId::new("workspace-b").unwrap()),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let mut missing = manifest.clone();
|
||||
missing.memory.as_mut().unwrap().settings_revision = None;
|
||||
assert!(
|
||||
validate_workspace_memory_snapshot(
|
||||
"memory-snapshot",
|
||||
&missing,
|
||||
&WorkerWorkspaceContext::unavailable(
|
||||
Some(WorkspaceId::new("workspace-a").unwrap()),
|
||||
"test",
|
||||
)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() {
|
||||
let mut manifest = WorkerManifest::from_toml(
|
||||
@@ -7064,6 +7257,52 @@ mod build_summary_prompt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_consolidation_prompt_uses_bound_workspace_language() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cwd = dir.path().join("workspace");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.profile = Some(manifest::ProfileManifestSnapshot {
|
||||
source: manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::Builtin,
|
||||
name: "memory-consolidation".to_string(),
|
||||
path: None,
|
||||
provenance: None,
|
||||
},
|
||||
profile: None,
|
||||
});
|
||||
let mut memory = manifest::MemoryConfig::default();
|
||||
memory.bind_workspace_settings(&manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 3,
|
||||
language: "Japanese".to_string(),
|
||||
});
|
||||
manifest.memory = Some(memory);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::new(NoopClient),
|
||||
store,
|
||||
WorkerWorkspaceContext::no_workspace(),
|
||||
WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()),
|
||||
Scope::writable(&cwd).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(
|
||||
SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::prompt::source::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
worker.ensure_system_prompt_materialized().await.unwrap();
|
||||
let prompt = worker.engine().get_system_prompt().unwrap();
|
||||
assert!(prompt.contains("`language`: `Japanese`"));
|
||||
assert!(!prompt.contains("`language`: `English`"));
|
||||
}
|
||||
|
||||
async fn render_system_prompt_with_summary(
|
||||
summary_doc: Option<&str>,
|
||||
memory_config: Option<manifest::MemoryConfig>,
|
||||
@@ -7358,6 +7597,9 @@ mod build_summary_prompt_tests {
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.memory = Some(manifest::MemoryConfig {
|
||||
extract_threshold: Some(1),
|
||||
workspace_id: Some("workspace-test".to_string()),
|
||||
settings_revision: Some(1),
|
||||
language: Some("English".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let memory_config = manifest.memory.clone().unwrap();
|
||||
@@ -7454,6 +7696,14 @@ mod build_summary_prompt_tests {
|
||||
assert_eq!(audits.len(), 2);
|
||||
assert_eq!(audits[0].run_id, audits[1].run_id);
|
||||
assert_eq!(audits[0].worker, memory::audit::AuditWorker::MemoryExtract);
|
||||
assert!(audits.iter().all(|audit| {
|
||||
audit.memory_settings
|
||||
== Some(memory::audit::MemorySettingsAudit {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
})
|
||||
}));
|
||||
assert_eq!(
|
||||
audits.iter().map(|audit| audit.status).collect::<Vec<_>>(),
|
||||
vec![
|
||||
|
||||
@@ -5,8 +5,13 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
typescript = ["dep:ts-rs"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workdir::workspace::WorkingDirectorySummary;
|
||||
|
||||
pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search";
|
||||
pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
@@ -32,6 +35,108 @@ pub struct ListResponse<T> {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct QueryPage {
|
||||
pub limit: usize,
|
||||
pub returned: usize,
|
||||
pub has_more: bool,
|
||||
pub next_cursor: Option<String>,
|
||||
pub sort: String,
|
||||
pub source_limit: Option<usize>,
|
||||
pub source_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveEventDetail {
|
||||
pub event_ref: String,
|
||||
pub kind: String,
|
||||
pub body: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveLinkedTicketSummary {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveResourceSummary {
|
||||
pub path: String,
|
||||
pub media_type: Option<String>,
|
||||
pub bytes: usize,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveSummary {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub summary: String,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveDetail {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub revision: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
||||
pub resources: Vec<ObjectiveResourceSummary>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub events: Vec<ObjectiveEventDetail>,
|
||||
pub event_page: QueryPage,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveCreateRequest {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body_md: String,
|
||||
#[serde(default = "default_objective_state")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_objective_state() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ObjectiveEditRequest {
|
||||
pub title: Option<String>,
|
||||
pub old_string: Option<String>,
|
||||
pub new_string: Option<String>,
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveStateRequest {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveLinkTicketRequest {
|
||||
pub ticket_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceKind {
|
||||
@@ -169,6 +274,23 @@ pub struct WorkerRestoreResponse {
|
||||
pub result: WorkerRestoreResult,
|
||||
}
|
||||
|
||||
/// Workspace-owned Memory settings returned by the shared Server API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceMemorySettings {
|
||||
pub workspace_id: String,
|
||||
pub settings_revision: u64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// Compare-and-swap update for Workspace-owned Memory settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateWorkspaceMemorySettingsRequest {
|
||||
pub expected_revision: u64,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,7 +12,7 @@ path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["typescript"]
|
||||
typescript = ["dep:ts-rs"]
|
||||
typescript = ["dep:ts-rs", "workspace-api/typescript"]
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -518,6 +518,9 @@ pub struct WorkerSpawnRequest {
|
||||
pub resolved_config_bundle: Option<ConfigBundle>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_workspace_api: Option<WorkspaceApiRef>,
|
||||
/// Backend-authored immutable Workspace Memory settings snapshot.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
/// Backend-owned feature enablement; client input cannot set it.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_worker_observation_enabled: bool,
|
||||
@@ -2184,6 +2187,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
worker_observation_enabled: request.resolved_worker_observation_enabled,
|
||||
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
||||
workspace_api: Some(workspace_api),
|
||||
memory_settings: request.resolved_memory_settings.clone(),
|
||||
};
|
||||
let workspace_scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend");
|
||||
match self
|
||||
@@ -3331,6 +3335,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
worker_observation_enabled: request.resolved_worker_observation_enabled,
|
||||
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
|
||||
workspace_api: Some(workspace_api),
|
||||
memory_settings: request.resolved_memory_settings.clone(),
|
||||
};
|
||||
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
|
||||
Ok(response) => WorkerSpawnResult {
|
||||
@@ -4408,6 +4413,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot {
|
||||
manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_summary_keeps_flat_wire_identity_while_using_structured_internal_identity() {
|
||||
let summary = placeholder_worker("placeholder");
|
||||
@@ -5007,6 +5020,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5246,6 +5260,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -5345,6 +5360,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -5383,6 +5399,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use workspace_api::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
|
||||
ObjectiveSummary, QueryPage,
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -226,18 +230,6 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct QueryPage {
|
||||
pub limit: usize,
|
||||
pub returned: usize,
|
||||
pub has_more: bool,
|
||||
pub next_cursor: Option<String>,
|
||||
pub sort: String,
|
||||
pub source_limit: Option<usize>,
|
||||
pub source_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ObjectiveLinkSummary {
|
||||
@@ -418,63 +410,6 @@ pub struct ObjectiveShowRequest {
|
||||
pub event_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ObjectiveEventDetail {
|
||||
pub event_ref: String,
|
||||
pub kind: String,
|
||||
pub body: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveLinkedTicketSummary {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveSummary {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub summary: String,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveDetail {
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub revision: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
||||
pub resources: Vec<ObjectiveResourceSummary>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
pub events: Vec<ObjectiveEventDetail>,
|
||||
pub event_page: QueryPage,
|
||||
pub record_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveResourceSummary {
|
||||
pub path: String,
|
||||
pub media_type: Option<String>,
|
||||
pub bytes: usize,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn ticket_api_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
|
||||
@@ -86,6 +86,11 @@ fn create_request(name: &str) -> CreateWorkerRequest {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ use workdir::workspace::{
|
||||
use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef};
|
||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use workspace_api::{
|
||||
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
|
||||
@@ -1182,8 +1186,11 @@ impl WorkspaceApi {
|
||||
&now_registry_timestamp(),
|
||||
)?;
|
||||
}
|
||||
let create_fingerprint = worker_spawn_create_fingerprint(&request)
|
||||
let request_fingerprint = worker_spawn_create_fingerprint(&request)
|
||||
.map_err(|message| Error::Config(message.to_string()))?;
|
||||
let current_memory_settings = self
|
||||
.config_store
|
||||
.get_workspace_memory_settings(&self.config.workspace_id)?;
|
||||
let allocation_key = request
|
||||
.resolved_control_operation
|
||||
.as_ref()
|
||||
@@ -1195,22 +1202,25 @@ impl WorkspaceApi {
|
||||
.map(|assignment| assignment.operation_id.clone())
|
||||
})
|
||||
.unwrap_or_else(|| format!("manual:{}", WorkerId::now_v7()));
|
||||
let worker_id = self
|
||||
let reservation = self
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
&self.config.workspace_id,
|
||||
runtime_id,
|
||||
&allocation_key,
|
||||
&create_fingerprint,
|
||||
&request_fingerprint,
|
||||
¤t_memory_settings,
|
||||
)
|
||||
.map_err(|error| Error::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "workspace_worker_allocation_conflict".to_string(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let worker_id = reservation.worker_id;
|
||||
request.resolved_memory_settings = Some(reservation.memory_settings);
|
||||
let create_binding = WorkerCreateBinding {
|
||||
worker_id,
|
||||
create_fingerprint,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
};
|
||||
let result = match self
|
||||
.runtime
|
||||
@@ -1594,6 +1604,10 @@ fn build_server_auth_router(api: ServerAuthApi) -> Router {
|
||||
|
||||
pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
let auth = build_server_auth_router(ServerAuthApi::from(&api));
|
||||
let scoped_ticket_relations_query_path =
|
||||
format!("/api/w/{{workspace_id}}{TICKET_RELATIONS_QUERY_PATH}");
|
||||
let scoped_ticket_orchestration_plans_query_path =
|
||||
format!("/api/w/{{workspace_id}}{TICKET_ORCHESTRATION_PLANS_QUERY_PATH}");
|
||||
let workspace = Router::new()
|
||||
.route("/api/workspace", get(get_workspace))
|
||||
.route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace))
|
||||
@@ -1601,6 +1615,11 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/settings/workspace",
|
||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/memory",
|
||||
get(scoped_get_workspace_memory_settings)
|
||||
.put(scoped_update_workspace_memory_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/config/source-tree",
|
||||
get(scoped_get_workspace_config_tree),
|
||||
@@ -1683,11 +1702,11 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
get(scoped_ticket_doctor),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/relations/search",
|
||||
scoped_ticket_relations_query_path.as_str(),
|
||||
post(scoped_query_ticket_relations),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/orchestration-plans/search",
|
||||
scoped_ticket_orchestration_plans_query_path.as_str(),
|
||||
post(scoped_query_ticket_orchestration_plans),
|
||||
)
|
||||
.route(
|
||||
@@ -2637,26 +2656,6 @@ struct ObjectiveListQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveCreateRequest {
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
body_md: String,
|
||||
#[serde(default = "default_objective_state")]
|
||||
state: String,
|
||||
#[serde(default)]
|
||||
linked_tickets: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveEditRequest {
|
||||
title: Option<String>,
|
||||
old_string: Option<String>,
|
||||
new_string: Option<String>,
|
||||
#[serde(default)]
|
||||
replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketListQuery {
|
||||
limit: Option<usize>,
|
||||
@@ -2665,16 +2664,6 @@ struct TicketListQuery {
|
||||
states: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveStateRequest {
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObjectiveLinkTicketRequest {
|
||||
ticket_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedObjectivePath {
|
||||
workspace_id: String,
|
||||
@@ -2688,10 +2677,6 @@ struct ScopedObjectiveTicketPath {
|
||||
ticket_id: String,
|
||||
}
|
||||
|
||||
fn default_objective_state() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranscriptQuery {
|
||||
start: Option<usize>,
|
||||
@@ -2998,6 +2983,43 @@ struct WorkspaceConfigTreeResponse {
|
||||
projection_digest: String,
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace_memory_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(workspace_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<workspace_api::WorkspaceMemorySettings>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let settings = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(&workspace_id)
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(workspace_api::WorkspaceMemorySettings {
|
||||
workspace_id: settings.workspace_id,
|
||||
settings_revision: settings.settings_revision,
|
||||
language: settings.language,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_update_workspace_memory_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(workspace_id): AxumPath<String>,
|
||||
Json(request): Json<workspace_api::UpdateWorkspaceMemorySettingsRequest>,
|
||||
) -> ApiResult<Json<workspace_api::WorkspaceMemorySettings>> {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let settings = api
|
||||
.config_store
|
||||
.update_workspace_memory_settings(
|
||||
&workspace_id,
|
||||
request.expected_revision,
|
||||
&request.language,
|
||||
)
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(workspace_api::WorkspaceMemorySettings {
|
||||
workspace_id: settings.workspace_id,
|
||||
settings_revision: settings.settings_revision,
|
||||
language: settings.language,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace_config_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -3655,13 +3677,10 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
|
||||
}
|
||||
|
||||
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
|
||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
Ok(SqliteTicketBackend::open_verified(
|
||||
api.config.database_path.clone(),
|
||||
api.config.workspace_id.clone(),
|
||||
)?
|
||||
.with_record_language(config.ticket_record_language())
|
||||
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
|
||||
api: api.clone(),
|
||||
})))
|
||||
@@ -3825,28 +3844,28 @@ fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_worker_ticket_rest_operation(
|
||||
async fn execute_ticket_rest_operation(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
headers: HeaderMap,
|
||||
mut operation: TicketBackendOperation,
|
||||
) -> ApiResult<TicketBackendOperationResult> {
|
||||
validate_workspace_scope(api, workspace_id)?;
|
||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
let mut backend = SqliteTicketBackend::open_verified(
|
||||
api.config.database_path.clone(),
|
||||
api.config.workspace_id.clone(),
|
||||
)
|
||||
.map_err(Error::from)?
|
||||
.with_record_language(config.ticket_record_language())
|
||||
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
|
||||
api: api.clone(),
|
||||
}));
|
||||
let operation_kind = ticket_mutation_operation_kind(&operation);
|
||||
let is_mutation = operation_kind != "read";
|
||||
let target = ticket_mutation_target(&operation).cloned();
|
||||
let source = authenticate_worker_mutation_source(api, workspace_id, &headers)?;
|
||||
// Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker
|
||||
// calls carry source headers; when either header is present, the complete pair is required
|
||||
// and authenticated before source attribution is attached.
|
||||
let source = optional_worker_mutation_source(api, workspace_id, &headers)?;
|
||||
reject_unguarded_ticket_completion(&operation)?;
|
||||
validate_ticket_repository_operation(api, &operation)?;
|
||||
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
|
||||
@@ -3854,9 +3873,12 @@ async fn execute_worker_ticket_rest_operation(
|
||||
.as_ref()
|
||||
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
|
||||
.unwrap_or_else(|| ticket_operation_initial_state(&operation));
|
||||
bind_worker_ticket_operation_source(&source, &mut operation);
|
||||
let source_context = worker_ticket_source_context(api, workspace_id, &source, before.as_ref());
|
||||
if let Some(source) = source.as_ref() {
|
||||
bind_worker_ticket_operation_source(source, &mut operation);
|
||||
let source_context =
|
||||
worker_ticket_source_context(api, workspace_id, source, before.as_ref());
|
||||
backend = backend.with_event_attributes(source_context.attributes(operation_kind));
|
||||
}
|
||||
|
||||
let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?;
|
||||
if is_mutation
|
||||
@@ -3869,7 +3891,7 @@ async fn execute_worker_ticket_rest_operation(
|
||||
&ticket.meta.id,
|
||||
&previous_state,
|
||||
ticket.meta.workflow_state.as_str(),
|
||||
Some(source),
|
||||
source,
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
@@ -3882,7 +3904,7 @@ async fn execute_worker_ticket_test_operation(
|
||||
headers: HeaderMap,
|
||||
Json(operation): Json<TicketBackendOperation>,
|
||||
) -> ApiResult<Json<TicketBackendOperationResult>> {
|
||||
execute_worker_ticket_rest_operation(&api, &path.workspace_id, headers, operation)
|
||||
execute_ticket_rest_operation(&api, &path.workspace_id, headers, operation)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
@@ -3918,7 +3940,7 @@ async fn scoped_default_intake_ready_body(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DefaultIntakeReadyBodyRequest>,
|
||||
) -> ApiResult<Json<String>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -3955,7 +3977,7 @@ async fn scoped_list_ticket_summaries(
|
||||
ticket::TicketListQuery::states(selected)
|
||||
}
|
||||
};
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -3973,7 +3995,7 @@ async fn scoped_get_ticket_record(
|
||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4003,7 +4025,7 @@ async fn scoped_create_ticket_record(
|
||||
"Ticket creation must start in planning; use guarded workflow operations for later states",
|
||||
));
|
||||
}
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -4022,7 +4044,7 @@ async fn scoped_edit_ticket_record_item(
|
||||
headers: HeaderMap,
|
||||
Json(edit): Json<TicketItemEdit>,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4043,7 +4065,7 @@ async fn scoped_ticket_dependency_check(
|
||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ticket::TicketDependencyCheck>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4064,7 +4086,7 @@ async fn scoped_add_ticket_thread_event(
|
||||
headers: HeaderMap,
|
||||
Json(event): Json<NewTicketEvent>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4083,7 +4105,7 @@ async fn scoped_add_ticket_state_change(
|
||||
headers: HeaderMap,
|
||||
Json(change): Json<TicketStateChange>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4102,7 +4124,7 @@ async fn scoped_add_ticket_intake_summary(
|
||||
headers: HeaderMap,
|
||||
Json(summary): Json<ticket::TicketIntakeSummary>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4130,7 +4152,7 @@ async fn scoped_set_ticket_state_field(
|
||||
headers: HeaderMap,
|
||||
Json(change): Json<TicketStateChange>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4150,7 +4172,7 @@ async fn scoped_set_ticket_workflow_state(
|
||||
headers: HeaderMap,
|
||||
Json(change): Json<TicketStateChange>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4169,7 +4191,7 @@ async fn scoped_mark_ticket_ready(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<TicketMarkReadyRequest>,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4195,7 +4217,7 @@ async fn scoped_queue_ticket_record(
|
||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4958,7 +4980,7 @@ async fn scoped_close_ticket_record(
|
||||
headers: HeaderMap,
|
||||
Json(resolution): Json<MarkdownText>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -4977,7 +4999,7 @@ async fn scoped_record_ticket_relation(
|
||||
headers: HeaderMap,
|
||||
Json(relation): Json<ticket::NewTicketRelation>,
|
||||
) -> ApiResult<Json<ticket::TicketRelation>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -5005,7 +5027,7 @@ async fn scoped_remove_ticket_relation(
|
||||
headers: HeaderMap,
|
||||
Json(relation): Json<TicketRelationRemoveRequest>,
|
||||
) -> ApiResult<Json<ticket::TicketRelation>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -5034,7 +5056,7 @@ async fn scoped_query_ticket_relations(
|
||||
headers: HeaderMap,
|
||||
Json(query): Json<TicketRelationSearchRequest>,
|
||||
) -> ApiResult<Json<Vec<ticket::TicketRelation>>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -5055,7 +5077,7 @@ async fn scoped_ticket_relation_view(
|
||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ticket::TicketRelationView>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -5076,7 +5098,7 @@ async fn scoped_record_ticket_orchestration_plan(
|
||||
headers: HeaderMap,
|
||||
Json(record): Json<ticket::NewOrchestrationPlanRecord>,
|
||||
) -> ApiResult<Json<ticket::OrchestrationPlanRecord>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
@@ -5104,7 +5126,7 @@ async fn scoped_query_ticket_orchestration_plans(
|
||||
headers: HeaderMap,
|
||||
Json(query): Json<TicketOrchestrationPlanSearchRequest>,
|
||||
) -> ApiResult<Json<Vec<ticket::OrchestrationPlanRecord>>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -5125,7 +5147,7 @@ async fn scoped_ticket_doctor(
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<ticket::TicketDoctorReport>> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
let result = execute_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
headers,
|
||||
@@ -6205,6 +6227,7 @@ fn start_memory_staging_consolidation(
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
},
|
||||
)?;
|
||||
if result.state != WorkerOperationState::Accepted {
|
||||
@@ -7271,6 +7294,7 @@ async fn scoped_start_workspace_orchestrator(
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
},
|
||||
)?;
|
||||
if result.state != WorkerOperationState::Accepted || result.worker.is_none() {
|
||||
@@ -9815,6 +9839,7 @@ async fn create_workspace_worker(
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
validate_ticket_assignment_spawn(&api, &runtime_id, &request)?;
|
||||
let assignment = request.ticket_assignment.clone();
|
||||
@@ -13243,6 +13268,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_worker_memory_settings() -> manifest::WorkspaceMemorySettingsSnapshot {
|
||||
manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_api_errors_preserve_http_status() {
|
||||
let not_found = ApiError::from(Error::Ticket(ticket::TicketError::NotFound(
|
||||
@@ -13511,6 +13544,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
assert!(
|
||||
api.validate_worker_spawn_repository_scope(&workdir_flow_launch)
|
||||
@@ -13683,6 +13717,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
|
||||
assert!(
|
||||
@@ -15160,6 +15195,7 @@ mod tests {
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -15373,6 +15409,7 @@ mod tests {
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -15449,12 +15486,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
for operation in operations {
|
||||
execute_worker_ticket_rest_operation(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
source_headers(),
|
||||
operation,
|
||||
)
|
||||
execute_ticket_rest_operation(&api, TEST_WORKSPACE_ID, source_headers(), operation)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -15583,6 +15615,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(EMBEDDED_WORKER_RUNTIME_ID)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
};
|
||||
let source_worker = api
|
||||
@@ -15848,6 +15881,7 @@ mod tests {
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -16117,6 +16151,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
let Json(first) = scoped_create_runtime_worker(
|
||||
State(api.clone()),
|
||||
@@ -16281,22 +16316,28 @@ mod tests {
|
||||
TEST_CREATED_AT,
|
||||
)
|
||||
.unwrap();
|
||||
let reserved_worker_id = api
|
||||
let current_memory_settings = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
"pending-spawn-operation",
|
||||
&pending_fingerprint,
|
||||
¤t_memory_settings,
|
||||
)
|
||||
.unwrap();
|
||||
pending_request.resolved_memory_settings = Some(reservation.memory_settings.clone());
|
||||
let spawned_before_backend_failure = api
|
||||
.runtime
|
||||
.spawn_worker(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
WorkerCreateBinding {
|
||||
worker_id: reserved_worker_id,
|
||||
create_fingerprint: pending_fingerprint.clone(),
|
||||
worker_id: reservation.worker_id,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
},
|
||||
pending_request.clone(),
|
||||
)
|
||||
@@ -16375,6 +16416,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
let Json(created) = scoped_create_runtime_worker(
|
||||
State(api.clone()),
|
||||
@@ -16478,6 +16520,12 @@ mod tests {
|
||||
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(".yoi/workspace.toml"),
|
||||
"this is not valid workspace config",
|
||||
)
|
||||
.unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let ticket_ref = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
@@ -16740,6 +16788,32 @@ mod tests {
|
||||
test_api_with_recording_backend(workspace_root).await.0
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_settings_handlers_reject_foreign_workspace_path_scope() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
assert!(
|
||||
scoped_get_workspace_memory_settings(
|
||||
State(api.clone()),
|
||||
AxumPath("workspace-foreign".to_string()),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
scoped_update_workspace_memory_settings(
|
||||
State(api),
|
||||
AxumPath("workspace-foreign".to_string()),
|
||||
Json(workspace_api::UpdateWorkspaceMemorySettingsRequest {
|
||||
expected_revision: 1,
|
||||
language: "English".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destructive_worker_remove_rejects_browser_and_legacy_source_headers() {
|
||||
let headers = HeaderMap::new();
|
||||
@@ -16935,6 +17009,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -16991,6 +17066,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -17916,6 +17992,7 @@ mod tests {
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18330,7 +18407,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_rest_search_requires_worker_source_identity_and_rpc_route_is_removed() {
|
||||
async fn ticket_rest_search_allows_workspace_product_clients_and_rejects_invalid_worker_source()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let app = build_router(api);
|
||||
@@ -18348,7 +18426,42 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/tickets"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&ticket::NewTicket::new("CLI Ticket")).unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
for path in [
|
||||
TICKET_RELATIONS_QUERY_PATH,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ticket":null,"kind":null}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "path: {path}");
|
||||
}
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
@@ -19210,6 +19323,7 @@ mod tests {
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||
"embedded-worker-runtime",
|
||||
)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
@@ -19729,6 +19843,7 @@ mod tests {
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
};
|
||||
let spawned = api
|
||||
.spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn_request)
|
||||
|
||||
@@ -8,6 +8,7 @@ use rusqlite::{
|
||||
Connection, OpenFlags, OptionalExtension, TransactionBehavior, backup::Backup, params,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use worker_runtime::identity::{
|
||||
@@ -230,6 +231,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "rename Workspace resource keys",
|
||||
apply: verify_workspace_resource_key_schema,
|
||||
},
|
||||
Migration {
|
||||
version: 42,
|
||||
name: "create Workspace Memory settings authority",
|
||||
apply: create_workspace_memory_settings_authority,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
@@ -261,6 +267,22 @@ pub struct WorkspaceRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceMemorySettingsRecord {
|
||||
pub workspace_id: String,
|
||||
pub settings_revision: u64,
|
||||
pub language: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerCreateReservation {
|
||||
pub worker_id: WorkerId,
|
||||
pub create_fingerprint: String,
|
||||
pub memory_settings: manifest::WorkspaceMemorySettingsSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RepositoryRecord {
|
||||
pub workspace_id: String,
|
||||
@@ -1140,23 +1162,125 @@ impl SqliteWorkspaceStore {
|
||||
f(&mut conn)
|
||||
}
|
||||
|
||||
pub(crate) fn get_workspace_memory_settings(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
) -> Result<WorkspaceMemorySettingsRecord> {
|
||||
let record = self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT workspace_id, settings_revision, language, created_at, updated_at \
|
||||
FROM workspace_memory_settings WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
|row| {
|
||||
let revision = row.get::<_, i64>(1)?;
|
||||
Ok(WorkspaceMemorySettingsRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
settings_revision: revision
|
||||
.try_into()
|
||||
.map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, revision))?,
|
||||
language: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string()))
|
||||
})?;
|
||||
validate_workspace_memory_settings_record(&record, workspace_id)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) fn update_workspace_memory_settings(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
expected_revision: u64,
|
||||
language: &str,
|
||||
) -> Result<WorkspaceMemorySettingsRecord> {
|
||||
let language = normalize_workspace_memory_language(language)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current = tx
|
||||
.query_row(
|
||||
"SELECT workspace_id, settings_revision, language, created_at, updated_at \
|
||||
FROM workspace_memory_settings WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
|row| {
|
||||
let revision = row.get::<_, i64>(1)?;
|
||||
Ok(WorkspaceMemorySettingsRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
settings_revision: revision.try_into().map_err(|_| {
|
||||
rusqlite::Error::IntegralValueOutOfRange(1, revision)
|
||||
})?,
|
||||
language: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string()))?;
|
||||
validate_workspace_memory_settings_record(¤t, workspace_id)?;
|
||||
let current_revision = current.settings_revision;
|
||||
if current_revision != expected_revision {
|
||||
return Err(Error::WorkspaceConfigConflict(format!(
|
||||
"Workspace Memory settings revision changed: expected {expected_revision}, current {current_revision}"
|
||||
)));
|
||||
}
|
||||
if current.language == language {
|
||||
tx.commit()?;
|
||||
return Ok(current);
|
||||
}
|
||||
let next_revision = current_revision.checked_add(1).ok_or_else(|| {
|
||||
Error::InvalidInput("Workspace Memory settings revision overflow".to_string())
|
||||
})?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
"UPDATE workspace_memory_settings \
|
||||
SET settings_revision = ?2, language = ?3, updated_at = ?4 \
|
||||
WHERE workspace_id = ?1",
|
||||
params![workspace_id, next_revision as i64, language, now],
|
||||
)?;
|
||||
let record = tx.query_row(
|
||||
"SELECT workspace_id, settings_revision, language, created_at, updated_at \
|
||||
FROM workspace_memory_settings WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
|row| {
|
||||
let revision = row.get::<_, i64>(1)?;
|
||||
Ok(WorkspaceMemorySettingsRecord {
|
||||
workspace_id: row.get(0)?,
|
||||
settings_revision: revision as u64,
|
||||
language: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(record)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn reserve_worker_create(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
allocation_key: &str,
|
||||
create_fingerprint: &str,
|
||||
) -> Result<WorkerId> {
|
||||
if allocation_key.trim().is_empty() || create_fingerprint.trim().is_empty() {
|
||||
request_fingerprint: &str,
|
||||
current_memory_settings: &WorkspaceMemorySettingsRecord,
|
||||
) -> Result<WorkerCreateReservation> {
|
||||
if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"Worker create allocation key and fingerprint must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?;
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let existing = tx
|
||||
.query_row(
|
||||
"SELECT worker_id, runtime_id, create_fingerprint \
|
||||
"SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \
|
||||
memory_settings_revision, memory_language \
|
||||
FROM worker_create_reservations \
|
||||
WHERE workspace_id = ?1 AND allocation_key = ?2",
|
||||
params![workspace_id, allocation_key],
|
||||
@@ -1164,24 +1288,80 @@ impl SqliteWorkspaceStore {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((worker_id, reserved_runtime_id, reserved_fingerprint)) = existing {
|
||||
if reserved_runtime_id != runtime_id || reserved_fingerprint != create_fingerprint {
|
||||
if let Some((worker_id, reserved_runtime_id, stored_request_fingerprint, create_fingerprint, revision, language)) = existing {
|
||||
if reserved_runtime_id != runtime_id
|
||||
|| stored_request_fingerprint.as_deref() != Some(request_fingerprint)
|
||||
{
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Worker create allocation `{allocation_key}` was already used with different input"
|
||||
"Worker create allocation {allocation_key} was already used with different input"
|
||||
)));
|
||||
}
|
||||
return worker_id.parse::<WorkerId>().map_err(|_| {
|
||||
Error::Store(format!(
|
||||
"Worker create allocation `{allocation_key}` has a non-UUIDv7 worker id"
|
||||
let revision = revision.ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Worker create allocation {allocation_key} has no persisted Memory settings snapshot"
|
||||
))
|
||||
})?;
|
||||
let language = language.ok_or_else(|| {
|
||||
Error::InvalidInput(format!(
|
||||
"Worker create allocation {allocation_key} has no persisted Memory language"
|
||||
))
|
||||
})?;
|
||||
let worker_id = worker_id.parse::<WorkerId>().map_err(|_| {
|
||||
Error::Store(format!(
|
||||
"Worker create allocation {allocation_key} has a non-UUIDv7 worker id"
|
||||
))
|
||||
})?;
|
||||
let snapshot = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: revision.try_into().map_err(|_| {
|
||||
rusqlite::Error::IntegralValueOutOfRange(4, revision)
|
||||
})?,
|
||||
language,
|
||||
};
|
||||
validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?;
|
||||
return Ok(WorkerCreateReservation {
|
||||
worker_id,
|
||||
create_fingerprint,
|
||||
memory_settings: snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
let (authoritative_revision, authoritative_language) = tx
|
||||
.query_row(
|
||||
"SELECT settings_revision, language FROM workspace_memory_settings WHERE workspace_id = ?1",
|
||||
params![workspace_id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store("Workspace Memory settings are missing".to_string()))?;
|
||||
let authoritative_revision: u64 = authoritative_revision.try_into().map_err(|_| {
|
||||
rusqlite::Error::IntegralValueOutOfRange(0, authoritative_revision)
|
||||
})?;
|
||||
if authoritative_revision != current_memory_settings.settings_revision
|
||||
|| authoritative_language != current_memory_settings.language
|
||||
{
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Workspace Memory settings changed while the Worker create reservation was being accepted"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: authoritative_revision,
|
||||
language: authoritative_language,
|
||||
};
|
||||
validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?;
|
||||
let create_fingerprint =
|
||||
bound_worker_create_fingerprint(request_fingerprint, &snapshot);
|
||||
let worker_id = WorkerId::now_v7();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
allocate_resource_key(
|
||||
@@ -1194,19 +1374,27 @@ impl SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
"INSERT INTO worker_create_reservations(\
|
||||
workspace_id, allocation_key, worker_id, runtime_id, create_fingerprint,\
|
||||
state, created_at, updated_at\
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?6)",
|
||||
state, created_at, updated_at, request_fingerprint,\
|
||||
memory_settings_revision, memory_language\
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, 'reserved', ?6, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
workspace_id,
|
||||
allocation_key,
|
||||
worker_id.to_string(),
|
||||
runtime_id,
|
||||
create_fingerprint,
|
||||
now
|
||||
now,
|
||||
request_fingerprint,
|
||||
snapshot.settings_revision as i64,
|
||||
snapshot.language,
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(worker_id)
|
||||
Ok(WorkerCreateReservation {
|
||||
worker_id,
|
||||
create_fingerprint,
|
||||
memory_settings: snapshot,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1375,8 +1563,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspaces (
|
||||
workspace_id, owner_account_id, display_name, state, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
@@ -1394,6 +1583,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.updated_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT OR IGNORE INTO workspace_memory_settings (
|
||||
workspace_id, settings_revision, language, created_at, updated_at
|
||||
) VALUES (?1, 1, 'English', ?2, ?3)"#,
|
||||
params![record.workspace_id, record.created_at, record.updated_at],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
self.materialize_workspace_config(&record.workspace_id, &record.created_at)
|
||||
@@ -1518,6 +1714,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.workspace.updated_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_memory_settings (
|
||||
workspace_id, settings_revision, language, created_at, updated_at
|
||||
) VALUES (?1, 1, 'English', ?2, ?3)"#,
|
||||
params![
|
||||
record.workspace.workspace_id,
|
||||
record.workspace.created_at,
|
||||
record.workspace.updated_at,
|
||||
],
|
||||
)?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO repositories (
|
||||
workspace_id, repository_id, name, kind, provider, uri, default_ref,
|
||||
@@ -5730,6 +5936,67 @@ fn collect_reference_diagnostics(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_workspace_memory_settings_snapshot(
|
||||
snapshot: &manifest::WorkspaceMemorySettingsSnapshot,
|
||||
expected_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
if snapshot.workspace_id != expected_workspace_id
|
||||
|| snapshot.settings_revision == 0
|
||||
|| !manifest::is_normalized_workspace_memory_language(&snapshot.language)
|
||||
{
|
||||
return Err(Error::Store(
|
||||
"Workspace Memory settings are corrupt or belong to another Workspace".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_workspace_memory_settings_record(
|
||||
record: &WorkspaceMemorySettingsRecord,
|
||||
expected_workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
validate_workspace_memory_settings_snapshot(
|
||||
&manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: record.workspace_id.clone(),
|
||||
settings_revision: record.settings_revision,
|
||||
language: record.language.clone(),
|
||||
},
|
||||
expected_workspace_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_workspace_memory_language(language: &str) -> Result<String> {
|
||||
let language = language.trim();
|
||||
if !manifest::is_normalized_workspace_memory_language(language) {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Workspace Memory language must be a non-empty UTF-8 string of at most {} characters without control characters",
|
||||
manifest::MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS
|
||||
)));
|
||||
}
|
||||
Ok(language.to_string())
|
||||
}
|
||||
|
||||
fn bound_worker_create_fingerprint(
|
||||
request_fingerprint: &str,
|
||||
snapshot: &manifest::WorkspaceMemorySettingsSnapshot,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"workspace-worker-create-v2\0");
|
||||
digest.update(request_fingerprint.as_bytes());
|
||||
digest.update(b"\0");
|
||||
digest.update(snapshot.workspace_id.as_bytes());
|
||||
digest.update(b"\0");
|
||||
digest.update(snapshot.settings_revision.to_be_bytes());
|
||||
digest.update(b"\0");
|
||||
digest.update(snapshot.language.as_bytes());
|
||||
let encoded = digest
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
format!("sha256:{encoded}")
|
||||
}
|
||||
|
||||
fn current_schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations",
|
||||
@@ -6373,6 +6640,42 @@ fn add_workspace_resource_human_keys(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace_memory_settings_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE workspace_memory_settings (
|
||||
workspace_id TEXT PRIMARY KEY NOT NULL,
|
||||
settings_revision INTEGER NOT NULL CHECK(settings_revision >= 1),
|
||||
language TEXT NOT NULL CHECK(length(trim(language)) > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO workspace_memory_settings (
|
||||
workspace_id,
|
||||
settings_revision,
|
||||
language,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT workspace_id, 1, 'English', created_at, updated_at
|
||||
FROM workspaces;
|
||||
|
||||
ALTER TABLE worker_create_reservations
|
||||
ADD COLUMN request_fingerprint TEXT;
|
||||
ALTER TABLE worker_create_reservations
|
||||
ADD COLUMN memory_settings_revision INTEGER;
|
||||
ALTER TABLE worker_create_reservations
|
||||
ADD COLUMN memory_language TEXT;
|
||||
|
||||
UPDATE worker_create_reservations
|
||||
SET request_fingerprint = create_fingerprint;
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_workspace_resource_key_schema(conn: &Connection) -> Result<()> {
|
||||
ticket::migrate_sqlite_ticket_resource_key_schema_in_transaction(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
@@ -7682,7 +7985,7 @@ mod tests {
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 36);
|
||||
assert_eq!(plan.target_schema_version, 41);
|
||||
assert_eq!(plan.target_schema_version, 42);
|
||||
assert!(plan.migration_required);
|
||||
assert_eq!(plan.worker_count, 1);
|
||||
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
|
||||
@@ -7696,7 +7999,7 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(table_exists(conn, "worker_diagnostics_archives")?);
|
||||
assert_eq!(current_schema_version(conn)?, 41);
|
||||
assert_eq!(current_schema_version(conn)?, 42);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -7775,7 +8078,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
let foreign_key_error: Option<String> = conn
|
||||
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
|
||||
.optional()
|
||||
@@ -7904,7 +8207,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||
let controller_worker_id: String = conn
|
||||
.query_row(
|
||||
@@ -8022,10 +8325,36 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v42_initializes_existing_workspaces_with_explicit_english_memory_settings() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations_through(&conn, 41).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) \
|
||||
VALUES ('workspace-existing', 'Existing', 'active', '1', '1')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
let settings = conn
|
||||
.query_row(
|
||||
"SELECT settings_revision, language FROM workspace_memory_settings \
|
||||
WHERE workspace_id = 'workspace-existing'",
|
||||
[],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings, (1, "English".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v26_removes_legacy_backend_flow_runtime_tables() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
@@ -8055,7 +8384,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
@@ -8122,7 +8451,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
let repositories_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||
@@ -8300,7 +8629,7 @@ INSERT INTO workdir_registry (
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 42);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -8317,7 +8646,7 @@ INSERT INTO workdir_registry (
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 42);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
@@ -8386,22 +8715,54 @@ INSERT INTO workdir_registry (
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let memory_settings = store.get_workspace_memory_settings("workspace-a").unwrap();
|
||||
assert_eq!(memory_settings.settings_revision, 1);
|
||||
assert_eq!(memory_settings.language, "English");
|
||||
let reserved = store
|
||||
.reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:one")
|
||||
.reserve_worker_create(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
"operation-1",
|
||||
"sha256:one",
|
||||
&memory_settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reserved.as_uuid().get_version(),
|
||||
reserved.worker_id.as_uuid().get_version(),
|
||||
Some(uuid::Version::SortRand)
|
||||
);
|
||||
assert_eq!(reserved.memory_settings.settings_revision, 1);
|
||||
assert_eq!(reserved.memory_settings.language, "English");
|
||||
let unchanged_memory_settings = store
|
||||
.update_workspace_memory_settings("workspace-a", 1, " English ")
|
||||
.unwrap();
|
||||
assert_eq!(unchanged_memory_settings, memory_settings);
|
||||
let updated_memory_settings = store
|
||||
.update_workspace_memory_settings("workspace-a", 1, " Français ")
|
||||
.unwrap();
|
||||
assert_eq!(updated_memory_settings.settings_revision, 2);
|
||||
assert_eq!(updated_memory_settings.language, "Français");
|
||||
assert_eq!(
|
||||
store
|
||||
.reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:one")
|
||||
.reserve_worker_create(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
"operation-1",
|
||||
"sha256:one",
|
||||
&updated_memory_settings,
|
||||
)
|
||||
.unwrap(),
|
||||
reserved
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reserve_worker_create("workspace-a", "arcadia", "operation-1", "sha256:different")
|
||||
.reserve_worker_create(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
"operation-1",
|
||||
"sha256:different",
|
||||
&updated_memory_settings,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -8409,7 +8770,7 @@ INSERT INTO workdir_registry (
|
||||
.resource_key(
|
||||
"workspace-a",
|
||||
WorkspaceResourceKind::Worker,
|
||||
&reserved.to_string()
|
||||
&reserved.worker_id.to_string()
|
||||
)
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
@@ -8419,37 +8780,85 @@ INSERT INTO workdir_registry (
|
||||
store
|
||||
.resolve_resource_reference("workspace-a", WorkspaceResourceKind::Worker, "W-1")
|
||||
.unwrap(),
|
||||
Some(reserved.to_string())
|
||||
Some(reserved.worker_id.to_string())
|
||||
);
|
||||
let second = store
|
||||
.reserve_worker_create("workspace-a", "arcadia", "operation-2", "sha256:two")
|
||||
.reserve_worker_create(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
"operation-2",
|
||||
"sha256:two",
|
||||
&updated_memory_settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second.memory_settings.settings_revision, 2);
|
||||
assert_eq!(
|
||||
store
|
||||
.resource_key(
|
||||
"workspace-a",
|
||||
WorkspaceResourceKind::Worker,
|
||||
&second.to_string()
|
||||
&second.worker_id.to_string()
|
||||
)
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("W-2")
|
||||
);
|
||||
store
|
||||
.complete_worker_create_reservation("workspace-a", reserved)
|
||||
.complete_worker_create_reservation("workspace-a", reserved.worker_id)
|
||||
.unwrap();
|
||||
let state: String = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT state FROM worker_create_reservations \
|
||||
WHERE workspace_id = 'workspace-a' AND worker_id = ?1",
|
||||
[reserved.to_string()],
|
||||
[reserved.worker_id.to_string()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(state, "created");
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE workspace_memory_settings SET language = ' English ' \
|
||||
WHERE workspace_id = 'workspace-a'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.get_workspace_memory_settings("workspace-a").is_err());
|
||||
assert!(
|
||||
store
|
||||
.update_workspace_memory_settings("workspace-a", 2, "Spanish")
|
||||
.is_err()
|
||||
);
|
||||
let mut corrupt = updated_memory_settings.clone();
|
||||
corrupt.language = " English ".to_string();
|
||||
assert!(
|
||||
store
|
||||
.reserve_worker_create(
|
||||
"workspace-a",
|
||||
"arcadia",
|
||||
"operation-corrupt",
|
||||
"sha256:corrupt",
|
||||
&corrupt,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM workspace_memory_settings WHERE workspace_id = 'workspace-a'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.get_workspace_memory_settings("workspace-a").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -8816,13 +9225,13 @@ INSERT INTO worker_registry (
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (42, 'future')",
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (43, 'future')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||
assert!(error.contains("schema version 42 is newer"), "{error}");
|
||||
assert!(error.contains("schema version 43 is newer"), "{error}");
|
||||
assert!(error.contains("refusing to serve"), "{error}");
|
||||
}
|
||||
|
||||
@@ -9043,7 +9452,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
|
||||
|
||||
apply_migrations(&mut conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 41);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 42);
|
||||
let workspace_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
|
||||
@@ -9660,7 +10069,7 @@ WHERE workspace_id = 'workspace-a'
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 42);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -9849,7 +10258,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 42);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -9915,7 +10324,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 42);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -10317,7 +10726,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 41);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 42);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
@@ -15,6 +15,7 @@ client = { workspace = true }
|
||||
memory = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
worker = { workspace = true }
|
||||
workspace-api = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
session-analytics = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
|
||||
@@ -55,14 +55,14 @@ impl CliCommand {
|
||||
CliCommand::DefaultTui
|
||||
| CliCommand::Workers
|
||||
| CliCommand::Resume
|
||||
| CliCommand::Panel => CliConnectionRequirement::ConnectionAware,
|
||||
| CliCommand::Panel
|
||||
| CliCommand::Ticket
|
||||
| CliCommand::Objective => CliConnectionRequirement::ConnectionAware,
|
||||
CliCommand::Login => CliConnectionRequirement::BackendOnly,
|
||||
CliCommand::Keys
|
||||
| CliCommand::SetupModel
|
||||
| CliCommand::WorkerRuntime
|
||||
| CliCommand::WorkerCleanup
|
||||
| CliCommand::Ticket
|
||||
| CliCommand::Objective
|
||||
| CliCommand::Plugin
|
||||
| CliCommand::Mcp
|
||||
| CliCommand::MemoryLint
|
||||
@@ -259,6 +259,8 @@ mod tests {
|
||||
CliCommand::Workers,
|
||||
CliCommand::Resume,
|
||||
CliCommand::Panel,
|
||||
CliCommand::Ticket,
|
||||
CliCommand::Objective,
|
||||
] {
|
||||
assert_eq!(
|
||||
command.connection_requirement(),
|
||||
@@ -277,8 +279,6 @@ mod tests {
|
||||
CliCommand::SetupModel,
|
||||
CliCommand::WorkerRuntime,
|
||||
CliCommand::WorkerCleanup,
|
||||
CliCommand::Ticket,
|
||||
CliCommand::Objective,
|
||||
CliCommand::Plugin,
|
||||
CliCommand::Mcp,
|
||||
CliCommand::MemoryLint,
|
||||
|
||||
+168
-30
@@ -34,10 +34,16 @@ enum Mode {
|
||||
MemoryLint(LintCliOptions),
|
||||
Mcp(mcp_cli::McpCliCommand),
|
||||
Plugin(plugin_cli::PluginCliCommand),
|
||||
Objective(objective_cli::ObjectiveCli),
|
||||
Objective {
|
||||
cli: objective_cli::ObjectiveCli,
|
||||
target: client::ResolvedTarget,
|
||||
},
|
||||
Session(session_cli::SessionCli),
|
||||
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
|
||||
Ticket(ticket_cli::TicketCli),
|
||||
Ticket {
|
||||
cli: ticket_cli::TicketCli,
|
||||
target: client::ResolvedTarget,
|
||||
},
|
||||
Login {
|
||||
backend_url: String,
|
||||
no_wait: bool,
|
||||
@@ -119,19 +125,25 @@ async fn main() -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Objective(cli) => match objective_cli::run(cli) {
|
||||
Ok(output) => {
|
||||
Mode::Objective { cli, target } => {
|
||||
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
|
||||
Ok(Ok(output)) => {
|
||||
print!("{}", output.stdout);
|
||||
match output.status {
|
||||
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS,
|
||||
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("yoi objective: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("yoi objective: execution task failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Session(cli) => match session_cli::run(cli) {
|
||||
Ok(output) => {
|
||||
print!("{}", output.stdout);
|
||||
@@ -158,19 +170,25 @@ async fn main() -> ExitCode {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Mode::Ticket(cli) => match ticket_cli::run(cli) {
|
||||
Ok(output) => {
|
||||
Mode::Ticket { cli, target } => {
|
||||
match tokio::task::spawn_blocking(move || ticket_cli::run(cli, target)).await {
|
||||
Ok(Ok(output)) => {
|
||||
print!("{}", output.stdout);
|
||||
match output.status {
|
||||
ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS,
|
||||
ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("yoi ticket: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("yoi ticket: execution task failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await,
|
||||
Mode::Keys => tui::keys::launch().await,
|
||||
Mode::SetupModel => tui::setup_model::launch().await,
|
||||
@@ -196,22 +214,15 @@ struct TargetSelection {
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TargetSelection {
|
||||
fn explicit_backend(&self) -> bool {
|
||||
self.backend_url.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
|
||||
connection_resolver: &R,
|
||||
command: CliCommand,
|
||||
selection: &TargetSelection,
|
||||
workspace_root: &Path,
|
||||
) -> Result<Box<dyn Target>, ParseError> {
|
||||
let workspace_id = if selection.explicit_backend() && selection.workspace_id.is_none() {
|
||||
resolve_workspace_id_from_root(workspace_root)?
|
||||
} else {
|
||||
selection.workspace_id.clone()
|
||||
let workspace_id = match selection.workspace_id.clone() {
|
||||
Some(workspace_id) => Some(workspace_id),
|
||||
None => resolve_workspace_id_from_root(workspace_root)?,
|
||||
};
|
||||
resolve_connection_aware_cli_connection(
|
||||
connection_resolver,
|
||||
@@ -357,10 +368,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
return Ok(Mode::WorkerRuntime(args[1..].to_vec()));
|
||||
}
|
||||
"objective" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?;
|
||||
let objective_cli = objective_cli::parse_objective_args(&args[1..])
|
||||
let workspace_root = current_dir()?;
|
||||
let target = resolve_tui_target(
|
||||
connection_resolver,
|
||||
CliCommand::Objective,
|
||||
&target_selection,
|
||||
&workspace_root,
|
||||
)?
|
||||
.resolve()
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
let cli = objective_cli::parse_objective_args(&args[1..])
|
||||
.map_err(|e| ParseError(e.to_string()))?;
|
||||
return Ok(Mode::Objective(objective_cli));
|
||||
return Ok(Mode::Objective { cli, target });
|
||||
}
|
||||
"session" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
|
||||
@@ -369,10 +388,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
return Ok(Mode::Session(session_cli));
|
||||
}
|
||||
"ticket" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?;
|
||||
let ticket_cli =
|
||||
let workspace_root = current_dir()?;
|
||||
let target = resolve_tui_target(
|
||||
connection_resolver,
|
||||
CliCommand::Ticket,
|
||||
&target_selection,
|
||||
&workspace_root,
|
||||
)?
|
||||
.resolve()
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
let cli =
|
||||
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
|
||||
return Ok(Mode::Ticket(ticket_cli));
|
||||
return Ok(Mode::Ticket { cli, target });
|
||||
}
|
||||
"plugin" => {
|
||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
|
||||
@@ -1660,8 +1687,8 @@ Local commands:
|
||||
worker [WORKER_OPTIONS] Run the local Worker runtime CLI
|
||||
worker delete <NAME> Delete local Worker records
|
||||
worker prune Prune old local Worker records
|
||||
ticket <COMMAND> Manage Tickets through the local workspace authority
|
||||
objective <COMMAND> Manage Objectives through the local workspace authority
|
||||
ticket <COMMAND> Manage Tickets through the selected target
|
||||
objective <COMMAND> Manage Objectives through the selected target
|
||||
plugin <COMMAND> Build/check/list/show plugins
|
||||
mcp <COMMAND> Inspect configured MCP servers
|
||||
memory lint Lint local memory files
|
||||
@@ -1721,6 +1748,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultBackendCliConnectionResolver {
|
||||
backend_url: &'static str,
|
||||
}
|
||||
|
||||
impl CliConnectionResolver for DefaultBackendCliConnectionResolver {
|
||||
fn resolve_connection(
|
||||
&self,
|
||||
_command: CliCommand,
|
||||
input: CliConnectionInput<'_>,
|
||||
) -> Result<Box<dyn Target>, ParseError> {
|
||||
let workspace_id = match input {
|
||||
CliConnectionInput::DefaultTarget { workspace_id }
|
||||
| CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id,
|
||||
CliConnectionInput::LocalTarget => return Ok(Box::new(LocalTarget::new())),
|
||||
};
|
||||
Ok(Box::new(BackendTarget::new(
|
||||
self.backend_url,
|
||||
workspace_id.map(str::to_string),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_uses_local_target_for_workers_without_backend_option() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
@@ -2102,14 +2151,100 @@ backend = "shared"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_backend_target_inherits_workspace_identity_from_workspace_root() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
|
||||
std::fs::write(
|
||||
workspace.path().join(".yoi/workspace.toml"),
|
||||
"workspace_id = \"workspace-from-root\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = DefaultBackendCliConnectionResolver {
|
||||
backend_url: "http://default-backend.example",
|
||||
};
|
||||
|
||||
let target = resolve_tui_target(
|
||||
&resolver,
|
||||
CliCommand::Ticket,
|
||||
&TargetSelection::default(),
|
||||
workspace.path(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
target.resolve().unwrap(),
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url: "http://default-backend.example".to_string(),
|
||||
workspace_id: "workspace-from-root".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ticket_subcommand_uses_ticket_mode() {
|
||||
match parse_args_from(["ticket", "doctor"]).unwrap() {
|
||||
Mode::Ticket(ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor)) => {}
|
||||
Mode::Ticket {
|
||||
cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor),
|
||||
target: client::ResolvedTarget::Local,
|
||||
} => {}
|
||||
_ => panic!("expected Ticket doctor mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_backend_ticket_keeps_resolved_workspace_target() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://fake-backend.example",
|
||||
};
|
||||
let args = vec![
|
||||
"--backend".to_string(),
|
||||
"http://ignored-by-fixed-resolver.example".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"workspace-a".to_string(),
|
||||
"ticket".to_string(),
|
||||
"doctor".to_string(),
|
||||
];
|
||||
|
||||
match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() {
|
||||
Mode::Ticket {
|
||||
cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor),
|
||||
target:
|
||||
client::ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
},
|
||||
} => {
|
||||
assert_eq!(base_url, "http://fake-backend.example");
|
||||
assert_eq!(workspace_id, "workspace-a");
|
||||
}
|
||||
other => panic!("expected Backend Ticket mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_backend_objective_keeps_resolved_workspace_target() {
|
||||
let resolver = FixedCliConnectionResolver {
|
||||
backend_url: "http://fake-backend.example",
|
||||
};
|
||||
let args = vec![
|
||||
"--backend".to_string(),
|
||||
"http://ignored-by-fixed-resolver.example".to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
"workspace-a".to_string(),
|
||||
"objective".to_string(),
|
||||
"doctor".to_string(),
|
||||
];
|
||||
|
||||
match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() {
|
||||
Mode::Objective {
|
||||
cli: objective_cli::ObjectiveCli::Command(objective_cli::ObjectiveCommand::Doctor),
|
||||
target: client::ResolvedTarget::Backend { workspace_id, .. },
|
||||
} => assert_eq!(workspace_id, "workspace-a"),
|
||||
other => panic!("expected Backend Objective mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_analyze_uses_session_mode() {
|
||||
match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() {
|
||||
@@ -2124,7 +2259,10 @@ backend = "shared"
|
||||
#[test]
|
||||
fn parse_ticket_help_uses_ticket_mode() {
|
||||
match parse_args_from(["ticket", "--help"]).unwrap() {
|
||||
Mode::Ticket(ticket_cli::TicketCli::Help) => {}
|
||||
Mode::Ticket {
|
||||
cli: ticket_cli::TicketCli::Help,
|
||||
target: client::ResolvedTarget::Local,
|
||||
} => {}
|
||||
_ => panic!("expected Ticket help mode"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use client::{BackendWorkspaceProductClient, ResolvedTarget};
|
||||
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
|
||||
use serde::Deserialize;
|
||||
use ticket::config::TicketConfig;
|
||||
@@ -167,11 +168,100 @@ pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCl
|
||||
Ok(ObjectiveCli::Command(command))
|
||||
}
|
||||
|
||||
pub fn run(cli: ObjectiveCli) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
|
||||
pub fn run(
|
||||
cli: ObjectiveCli,
|
||||
target: ResolvedTarget,
|
||||
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
|
||||
match target {
|
||||
ResolvedTarget::Local => {
|
||||
let workspace = std::env::current_dir().map_err(|error| {
|
||||
ObjectiveCliError::new(format!("failed to resolve current directory: {error}"))
|
||||
})?;
|
||||
run_in_workspace(cli, &workspace)
|
||||
}
|
||||
ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} => {
|
||||
let backend = BackendWorkspaceProductClient::new(base_url, workspace_id)
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
run_with_backend(cli, &backend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_with_backend(
|
||||
cli: ObjectiveCli,
|
||||
backend: &BackendWorkspaceProductClient,
|
||||
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
|
||||
match cli {
|
||||
ObjectiveCli::Help => Ok(success(help_text().to_string())),
|
||||
ObjectiveCli::Command(ObjectiveCommand::Create(options)) => {
|
||||
let title = options.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(ObjectiveCliError::new("create --title must not be empty"));
|
||||
}
|
||||
let objective = backend
|
||||
.create_objective(&workspace_api::ObjectiveCreateRequest {
|
||||
title: title.to_string(),
|
||||
body_md: objective_body_template(),
|
||||
state: "active".to_string(),
|
||||
linked_tickets: options.linked_tickets,
|
||||
})
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
Ok(success(format!("created\t{}\n", objective.id)))
|
||||
}
|
||||
ObjectiveCli::Command(ObjectiveCommand::List(options)) => {
|
||||
let response = backend
|
||||
.list_objectives(BackendWorkspaceProductClient::default_product_list_limit())
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
let mut stdout = String::from("state\tid\ttitle\tupdated_at\tlinked_tickets\n");
|
||||
for objective in response.items {
|
||||
let state = ObjectiveState::parse(&objective.state);
|
||||
if !list_state_matches(options.state, state) {
|
||||
continue;
|
||||
}
|
||||
stdout.push_str(&format!(
|
||||
"{}\t{}\t{}\t{}\t{}\n",
|
||||
objective.state,
|
||||
objective.id,
|
||||
objective.title,
|
||||
objective.updated_at.unwrap_or_default(),
|
||||
objective.linked_tickets.join(",")
|
||||
));
|
||||
}
|
||||
Ok(success(stdout))
|
||||
}
|
||||
ObjectiveCli::Command(ObjectiveCommand::Show { id }) => {
|
||||
let objective = backend
|
||||
.show_objective(&id)
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
let mut stdout = String::new();
|
||||
stdout.push_str(&format!("# {}\n\n", objective.title));
|
||||
stdout.push_str(&format!("State: {}\n", objective.state));
|
||||
stdout.push_str(&format!("ID: {}\n", objective.id));
|
||||
stdout.push_str(&format!(
|
||||
"Updated: {}\n\n## item.md\n\n",
|
||||
objective.updated_at.unwrap_or_default()
|
||||
));
|
||||
stdout.push_str(&objective.body);
|
||||
if !stdout.ends_with('\n') {
|
||||
stdout.push('\n');
|
||||
}
|
||||
Ok(success(stdout))
|
||||
}
|
||||
ObjectiveCli::Command(ObjectiveCommand::Doctor) => {
|
||||
let response = backend
|
||||
.list_objectives(BackendWorkspaceProductClient::default_product_list_limit())
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
for objective in response.items {
|
||||
backend
|
||||
.show_objective(&objective.id)
|
||||
.map_err(|error| ObjectiveCliError::new(error.to_string()))?;
|
||||
}
|
||||
Ok(success("doctor: ok\n".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_in_workspace(
|
||||
@@ -453,15 +543,21 @@ fn list_state_matches(filter: ObjectiveListState, state: Option<ObjectiveState>)
|
||||
}
|
||||
}
|
||||
|
||||
fn objective_body_template() -> String {
|
||||
"## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn render_objective_item(title: &str, linked_tickets: &[String]) -> String {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
format!(
|
||||
"---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n\n",
|
||||
"---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n{}\n",
|
||||
yaml_string(title),
|
||||
yaml_string(ObjectiveState::Active.as_str()),
|
||||
yaml_string(&now),
|
||||
yaml_string(&now),
|
||||
yaml_string_array(linked_tickets)
|
||||
yaml_string_array(linked_tickets),
|
||||
objective_body_template()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -587,7 +683,7 @@ fn success(stdout: String) -> ObjectiveCliOutput {
|
||||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nObjective records are lightweight project records stored as .yoi/objectives/<objective-id>/item.md. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n"
|
||||
"yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nBackend targets use the Workspace-scoped Objective API selected by the shared client Target. Explicit local targets preserve the repository-file Objective backend. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use client::{BackendWorkspaceProductClient, ResolvedTarget};
|
||||
use ticket::config::{
|
||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
ticket_config_scaffold,
|
||||
@@ -205,11 +206,32 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
||||
Ok(TicketCli::Command(command))
|
||||
}
|
||||
|
||||
pub fn run(cli: TicketCli) -> Result<TicketCliOutput, TicketCliError> {
|
||||
pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match target {
|
||||
ResolvedTarget::Local => {
|
||||
let workspace = std::env::current_dir().map_err(|error| {
|
||||
TicketCliError::new(format!("failed to resolve current directory: {error}"))
|
||||
})?;
|
||||
run_in_workspace(cli, &workspace)
|
||||
}
|
||||
ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} => match cli {
|
||||
TicketCli::Help => Ok(TicketCliOutput {
|
||||
status: TicketCliStatus::Success,
|
||||
stdout: help_text().to_string(),
|
||||
}),
|
||||
TicketCli::Command(TicketCommand::Init | TicketCommand::ImportLocal) => Err(
|
||||
TicketCliError::new("ticket init/import-local require an explicit local target"),
|
||||
),
|
||||
TicketCli::Command(command) => {
|
||||
let backend = BackendWorkspaceProductClient::new(base_url, workspace_id)
|
||||
.map_err(|error| TicketCliError::new(error.to_string()))?;
|
||||
run_backend_command(command, &backend)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_in_workspace(
|
||||
@@ -234,20 +256,27 @@ fn run_command(
|
||||
TicketCommand::ImportLocal => import_local(workspace),
|
||||
command => {
|
||||
let backend = backend_for_workspace(workspace)?;
|
||||
run_backend_command(command, backend.as_ref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_backend_command(
|
||||
command: TicketCommand,
|
||||
backend: &dyn TicketBackend,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
match command {
|
||||
TicketCommand::Create(options) => create(backend.as_ref(), options),
|
||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||
TicketCommand::Doctor => doctor(backend.as_ref()),
|
||||
TicketCommand::Init | TicketCommand::ImportLocal => {
|
||||
unreachable!("handled before backend setup")
|
||||
}
|
||||
}
|
||||
}
|
||||
TicketCommand::Create(options) => create(backend, options),
|
||||
TicketCommand::List(options) => list(backend, options),
|
||||
TicketCommand::Show { query } => show(backend, query),
|
||||
TicketCommand::Comment(options) => comment(backend, options),
|
||||
TicketCommand::State(options) => state(backend, options),
|
||||
TicketCommand::Close(options) => close(backend, options),
|
||||
TicketCommand::Relation(options) => relation(backend, options),
|
||||
TicketCommand::Doctor => doctor(backend),
|
||||
TicketCommand::Init | TicketCommand::ImportLocal => Err(TicketCliError::new(
|
||||
"ticket init/import-local require an explicit local target",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,7 +1182,7 @@ fn default_author() -> String {
|
||||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Backend targets use the Workspace-scoped Ticket API selected by the shared client Target.\n Explicit local targets use the workspace SQLite backend. `init` and `import-local` are local-only.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoHash = "sha256-R33Ty4414wGkqnwCt08zbDQbVu9ggMC5I3ZawgloNT0=";
|
||||
cargoHash = "sha256-LjswbsalQA0Yny0DIBJg3FiqwdmTADZ1CnqNJ9oo9Kw=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
||||
Reference in New Issue
Block a user