feat(sdk,plugins): API publique d'accès fichiers/workspace + analyse structure (#124,#129)

This commit is contained in:
2026-08-02 13:34:54 +02:00
parent 033e9a86d5
commit dce61ae1aa
27 changed files with 5895 additions and 94 deletions

View File

@ -7,6 +7,7 @@
//! JSON convention already used in the domain (`agents.json` etc.).
use serde::{Deserialize, Serialize};
use serde_json::Value;
use application::{
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
@ -273,6 +274,408 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
}
}
/// Plugin workspace path request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspacePathDto {
/// Project id.
pub project_id: String,
/// Relative path under the project root.
pub path: String,
}
impl From<PluginWorkspacePathDto> for application::PluginWorkspacePathInput {
fn from(value: PluginWorkspacePathDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
}
}
}
/// Plugin workspace text write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceWriteTextDto {
/// Project id.
pub project_id: String,
/// Relative path under the project root.
pub path: String,
/// UTF-8 content.
pub content: String,
}
impl From<PluginWorkspaceWriteTextDto> for application::PluginWorkspaceWriteTextInput {
fn from(value: PluginWorkspaceWriteTextDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
content: value.content,
}
}
}
/// Plugin workspace binary write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceWriteBinaryDto {
/// Project id.
pub project_id: String,
/// Relative path under the project root.
pub path: String,
/// Raw bytes.
pub bytes: Vec<u8>,
}
impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBinaryInput {
fn from(value: PluginWorkspaceWriteBinaryDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
bytes: value.bytes,
}
}
}
/// Plugin structured config document read request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConfigDocumentReadDto {
/// Project id.
pub project_id: String,
/// Relative path under the project root.
pub path: String,
/// Optional explicit format. Omitted means inferred from extension.
#[serde(default)]
pub format: Option<String>,
}
impl From<PluginConfigDocumentReadDto> for application::PluginConfigDocumentReadInput {
fn from(value: PluginConfigDocumentReadDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
format: value.format,
}
}
}
/// Plugin structured config document update request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConfigDocumentUpdateDto {
/// Project id.
pub project_id: String,
/// Relative path under the project root.
pub path: String,
/// Optional explicit format. Omitted means inferred from extension.
#[serde(default)]
pub format: Option<String>,
/// Update mode: `mergePatch` (default) or `replace`.
#[serde(default)]
pub mode: Option<String>,
/// JSON replacement or merge patch.
pub value: Value,
}
impl From<PluginConfigDocumentUpdateDto> for application::PluginConfigDocumentUpdateInput {
fn from(value: PluginConfigDocumentUpdateDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
format: value.format,
mode: value.mode,
value: value.value,
}
}
}
/// Plugin project structure query request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginProjectStructureQueryDto {
/// Project id.
pub project_id: String,
/// Optional relative root path.
#[serde(default)]
pub path: Option<String>,
/// Optional traversal depth.
#[serde(default)]
pub max_depth: Option<u8>,
/// Optional entry cap.
#[serde(default)]
pub max_entries: Option<usize>,
}
impl From<PluginProjectStructureQueryDto> for application::QueryProjectStructureInput {
fn from(value: PluginProjectStructureQueryDto) -> Self {
Self {
project_id: value.project_id,
path: value.path,
max_depth: value.max_depth,
max_entries: value.max_entries,
}
}
}
/// Public plugin command-task launch request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunCommandDto {
/// Owning project id.
pub project_id: String,
/// Agent id used for Work correlation and completion wake delivery.
pub owner_agent_id: String,
/// Human-facing task label.
pub label: String,
/// Executable to run.
pub command: String,
/// Arguments passed without shell parsing.
#[serde(default)]
pub args: Vec<String>,
/// Relative working directory under project root. Empty/omitted means root.
#[serde(default)]
pub cwd: Option<String>,
/// Extra environment variables.
#[serde(default)]
pub env: Vec<(String, String)>,
/// When true, completion is only recorded; otherwise the owner is woken.
#[serde(default)]
pub record_only: bool,
/// Optional absolute deadline, epoch milliseconds.
#[serde(default)]
pub deadline_ms: Option<u64>,
}
impl From<PluginRunCommandDto> for application::PluginRunCommandInput {
fn from(value: PluginRunCommandDto) -> Self {
Self {
project_id: value.project_id,
owner_agent_id: value.owner_agent_id,
label: value.label,
command: value.command,
args: value.args,
cwd: value.cwd,
env: value.env,
record_only: value.record_only,
deadline_ms: value.deadline_ms,
}
}
}
/// Public plugin task status request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskStatusDto {
/// Task id to read.
pub task_id: String,
}
impl From<PluginTaskStatusDto> for application::PluginTaskStatusInput {
fn from(value: PluginTaskStatusDto) -> Self {
Self {
task_id: value.task_id,
}
}
}
/// Public plugin command-task status/output DTO.
pub type PluginTaskDto = BackgroundTaskDto;
/// Public plugin external-toolchain diagnostic request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolchainDiagnosticRequestDto {
/// Owning project id.
pub project_id: String,
/// Relative working directory under project root.
#[serde(default)]
pub cwd: Option<String>,
/// Executable probes to run.
#[serde(default)]
pub tools: Vec<PluginToolRequirementDto>,
/// Environment variable prerequisites.
#[serde(default)]
pub env: Vec<PluginEnvRequirementDto>,
/// Workspace file prerequisites.
#[serde(default)]
pub files: Vec<PluginFileRequirementDto>,
}
impl From<PluginToolchainDiagnosticRequestDto> for application::PluginToolchainDiagnosticInput {
fn from(value: PluginToolchainDiagnosticRequestDto) -> Self {
Self {
project_id: value.project_id,
cwd: value.cwd,
tools: value.tools.into_iter().map(Into::into).collect(),
env: value.env.into_iter().map(Into::into).collect(),
files: value.files.into_iter().map(Into::into).collect(),
}
}
}
/// Public plugin executable probe DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolRequirementDto {
/// Stable requirement id.
pub id: String,
/// Executable name or path.
pub executable: String,
/// Version/diagnostic arguments.
#[serde(default)]
pub version_args: Vec<String>,
/// Whether this probe is required.
#[serde(default)]
pub required: bool,
/// Extra environment variables for the probe.
#[serde(default)]
pub env: Vec<(String, String)>,
}
impl From<PluginToolRequirementDto> for application::PluginToolRequirement {
fn from(value: PluginToolRequirementDto) -> Self {
Self {
id: value.id,
executable: value.executable,
version_args: value.version_args,
required: value.required,
env: value.env,
}
}
}
/// Public plugin environment prerequisite DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEnvRequirementDto {
/// Environment variable name.
pub name: String,
/// Whether this variable is required.
#[serde(default)]
pub required: bool,
/// Optional exact expected value.
#[serde(default)]
pub equals: Option<String>,
}
impl From<PluginEnvRequirementDto> for application::PluginEnvRequirement {
fn from(value: PluginEnvRequirementDto) -> Self {
Self {
name: value.name,
required: value.required,
equals: value.equals,
}
}
}
/// Public plugin workspace file prerequisite DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginFileRequirementDto {
/// Relative path under project root.
pub path: String,
/// Whether this path is required.
#[serde(default)]
pub required: bool,
/// Expected kind: `file`, `directory`, or `any`.
#[serde(default)]
pub kind: Option<String>,
}
impl From<PluginFileRequirementDto> for application::PluginFileRequirement {
fn from(value: PluginFileRequirementDto) -> Self {
Self {
path: value.path,
required: value.required,
kind: value.kind,
}
}
}
/// Public plugin external-toolchain diagnostic output DTO.
pub type PluginToolchainDiagnosticDto = application::PluginToolchainDiagnostic;
/// Public plugin event subscription request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventSubscribeDto {
/// Project id to observe.
pub project_id: String,
/// Public event types to retain. Empty means all supported types.
#[serde(default)]
pub event_types: Vec<String>,
/// Per-subscription retained event capacity.
#[serde(default)]
pub capacity: Option<usize>,
}
impl From<PluginEventSubscribeDto> for application::PluginEventSubscribeInput {
fn from(value: PluginEventSubscribeDto) -> Self {
Self {
project_id: value.project_id,
event_types: value.event_types,
capacity: value.capacity,
}
}
}
/// Public plugin event poll request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventPollDto {
/// Subscription id returned by subscribe.
pub subscription_id: String,
/// Maximum number of events to drain.
#[serde(default)]
pub max_events: Option<usize>,
}
impl From<PluginEventPollDto> for application::PluginEventPollInput {
fn from(value: PluginEventPollDto) -> Self {
Self {
subscription_id: value.subscription_id,
max_events: value.max_events,
}
}
}
/// Public plugin event unsubscribe request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventUnsubscribeDto {
/// Subscription id returned by subscribe.
pub subscription_id: String,
}
impl From<PluginEventUnsubscribeDto> for application::PluginEventUnsubscribeInput {
fn from(value: PluginEventUnsubscribeDto) -> Self {
Self {
subscription_id: value.subscription_id,
}
}
}
/// Public plugin event subscription output DTO.
pub type PluginEventSubscriptionDto = application::PluginEventSubscription;
/// Public plugin event poll output DTO.
pub type PluginEventBatchDto = application::PluginEventBatch;
/// Plugin workspace text file DTO.
pub type PluginWorkspaceTextFileDto = application::PluginWorkspaceTextFile;
/// Plugin workspace binary file DTO.
pub type PluginWorkspaceBinaryFileDto = application::PluginWorkspaceBinaryFile;
/// Plugin workspace directory listing DTO.
pub type PluginWorkspaceDirectoryListingDto = application::PluginWorkspaceDirectoryListing;
/// Plugin workspace stat DTO.
pub type PluginWorkspaceStatDto = application::PluginWorkspaceStat;
/// Plugin structured config document DTO.
pub type PluginConfigDocumentDto = application::PluginConfigDocument;
/// Plugin structured config document write result DTO.
pub type PluginConfigDocumentWriteResultDto = application::PluginConfigDocumentWriteResult;
/// Plugin project structure result DTO.
pub type PluginProjectStructureDto = application::ProjectStructureQuery;
/// Request DTO for the `health` command.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -4352,6 +4755,356 @@ mod tests {
);
}
#[test]
fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
let path = PluginWorkspacePathDto {
project_id: Uuid::from_u128(124).to_string(),
path: "src/main.rs".to_owned(),
};
let text = PluginWorkspaceWriteTextDto {
project_id: path.project_id.clone(),
path: path.path.clone(),
content: "fn main() {}\n".to_owned(),
};
let binary = PluginWorkspaceWriteBinaryDto {
project_id: path.project_id.clone(),
path: "assets/icon.bin".to_owned(),
bytes: vec![1, 2, 3],
};
assert_eq!(
serde_json::to_value(&path).unwrap(),
json!({
"projectId": path.project_id,
"path": "src/main.rs"
})
);
assert_eq!(
serde_json::to_value(&text).unwrap(),
json!({
"projectId": text.project_id,
"path": "src/main.rs",
"content": "fn main() {}\n"
})
);
assert_eq!(
serde_json::to_value(&binary).unwrap(),
json!({
"projectId": binary.project_id,
"path": "assets/icon.bin",
"bytes": [1, 2, 3]
})
);
}
#[test]
fn dto_plugins_project_structure_query_maps_to_application_input() {
let dto = PluginProjectStructureQueryDto {
project_id: Uuid::from_u128(129).to_string(),
path: Some("crates".to_owned()),
max_depth: Some(4),
max_entries: Some(250),
};
let json = serde_json::to_value(&dto).unwrap();
assert_eq!(
json,
json!({
"projectId": dto.project_id,
"path": "crates",
"maxDepth": 4,
"maxEntries": 250
})
);
let input: application::QueryProjectStructureInput = dto.into();
assert_eq!(input.path.as_deref(), Some("crates"));
assert_eq!(input.max_depth, Some(4));
assert_eq!(input.max_entries, Some(250));
}
#[test]
fn dto_plugins_config_document_requests_use_stable_camel_case_contract() {
let project_id = Uuid::from_u128(130).to_string();
let read = PluginConfigDocumentReadDto {
project_id: project_id.clone(),
path: "config/settings.json".to_owned(),
format: Some("json".to_owned()),
};
assert_eq!(
serde_json::to_value(&read).unwrap(),
json!({
"projectId": project_id,
"path": "config/settings.json",
"format": "json"
})
);
let input: application::PluginConfigDocumentReadInput = read.into();
assert_eq!(input.path, "config/settings.json");
assert_eq!(input.format.as_deref(), Some("json"));
let update = PluginConfigDocumentUpdateDto {
project_id: Uuid::from_u128(130).to_string(),
path: "config/settings.json".to_owned(),
format: Some("json".to_owned()),
mode: Some("mergePatch".to_owned()),
value: json!({"enabled": true, "removeMe": null}),
};
assert_eq!(
serde_json::to_value(&update).unwrap(),
json!({
"projectId": Uuid::from_u128(130).to_string(),
"path": "config/settings.json",
"format": "json",
"mode": "mergePatch",
"value": {
"enabled": true,
"removeMe": null
}
})
);
let input: application::PluginConfigDocumentUpdateInput = update.into();
assert_eq!(input.mode.as_deref(), Some("mergePatch"));
assert_eq!(input.value["enabled"], true);
}
#[test]
fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
let listing = PluginWorkspaceDirectoryListingDto {
path: "src".to_owned(),
entries: vec![application::PluginWorkspaceDirEntry {
name: "main.rs".to_owned(),
path: "src/main.rs".to_owned(),
is_dir: false,
}],
};
let stat = PluginWorkspaceStatDto {
path: "src/main.rs".to_owned(),
exists: true,
is_file: true,
is_dir: false,
len: Some(13),
};
let structure = PluginProjectStructureDto {
project_id: Uuid::from_u128(129).to_string(),
root_path: String::new(),
entries: vec![application::ProjectStructureEntry {
path: "Cargo.toml".to_owned(),
name: "Cargo.toml".to_owned(),
kind: "file".to_owned(),
}],
conventions: vec![application::ProjectConvention {
id: "rust-cargo".to_owned(),
marker_path: "Cargo.toml".to_owned(),
}],
modules: vec![application::ProjectModule {
path: String::new(),
marker_path: "Cargo.toml".to_owned(),
convention_id: "rust-cargo".to_owned(),
}],
truncated: false,
};
assert_eq!(
serde_json::to_value(&listing).unwrap(),
json!({
"path": "src",
"entries": [{
"name": "main.rs",
"path": "src/main.rs",
"isDir": false
}]
})
);
assert_eq!(
serde_json::to_value(&stat).unwrap(),
json!({
"path": "src/main.rs",
"exists": true,
"isFile": true,
"isDir": false,
"len": 13
})
);
assert_eq!(
serde_json::to_value(&structure).unwrap(),
json!({
"projectId": structure.project_id,
"rootPath": "",
"entries": [{
"path": "Cargo.toml",
"name": "Cargo.toml",
"kind": "file"
}],
"conventions": [{
"id": "rust-cargo",
"markerPath": "Cargo.toml"
}],
"modules": [{
"path": "",
"markerPath": "Cargo.toml",
"conventionId": "rust-cargo"
}],
"truncated": false
})
);
}
#[test]
fn dto_plugins_command_task_requests_use_stable_camel_case_contract() {
let project_id = Uuid::from_u128(125).to_string();
let owner_agent_id = Uuid::from_u128(126).to_string();
let run = PluginRunCommandDto {
project_id: project_id.clone(),
owner_agent_id: owner_agent_id.clone(),
label: "cargo test".to_owned(),
command: "cargo".to_owned(),
args: vec!["test".to_owned(), "-p".to_owned(), "application".to_owned()],
cwd: Some("crates/application".to_owned()),
env: vec![("RUST_LOG".to_owned(), "debug".to_owned())],
record_only: true,
deadline_ms: Some(1_800_000_000_000),
};
assert_eq!(
serde_json::to_value(&run).unwrap(),
json!({
"projectId": project_id,
"ownerAgentId": owner_agent_id,
"label": "cargo test",
"command": "cargo",
"args": ["test", "-p", "application"],
"cwd": "crates/application",
"env": [["RUST_LOG", "debug"]],
"recordOnly": true,
"deadlineMs": 1_800_000_000_000u64
})
);
let input: application::PluginRunCommandInput = run.into();
assert_eq!(input.cwd.as_deref(), Some("crates/application"));
assert_eq!(input.env, vec![("RUST_LOG".to_owned(), "debug".to_owned())]);
assert!(input.record_only);
let status = PluginTaskStatusDto {
task_id: Uuid::from_u128(127).to_string(),
};
assert_eq!(
serde_json::to_value(&status).unwrap(),
json!({ "taskId": status.task_id })
);
}
#[test]
fn dto_plugins_toolchain_diagnostic_request_maps_to_application_input() {
let project_id = Uuid::from_u128(126).to_string();
let request = PluginToolchainDiagnosticRequestDto {
project_id: project_id.clone(),
cwd: Some("crates/backend".to_owned()),
tools: vec![PluginToolRequirementDto {
id: "rust".to_owned(),
executable: "cargo".to_owned(),
version_args: vec!["--version".to_owned()],
required: true,
env: vec![("CARGO_TERM_COLOR".to_owned(), "never".to_owned())],
}],
env: vec![PluginEnvRequirementDto {
name: "RUSTUP_HOME".to_owned(),
required: false,
equals: None,
}],
files: vec![PluginFileRequirementDto {
path: "Cargo.toml".to_owned(),
required: true,
kind: Some("file".to_owned()),
}],
};
assert_eq!(
serde_json::to_value(&request).unwrap(),
json!({
"projectId": project_id,
"cwd": "crates/backend",
"tools": [{
"id": "rust",
"executable": "cargo",
"versionArgs": ["--version"],
"required": true,
"env": [["CARGO_TERM_COLOR", "never"]]
}],
"env": [{
"name": "RUSTUP_HOME",
"required": false,
"equals": null
}],
"files": [{
"path": "Cargo.toml",
"required": true,
"kind": "file"
}]
})
);
let input: application::PluginToolchainDiagnosticInput = request.into();
assert_eq!(input.cwd.as_deref(), Some("crates/backend"));
assert_eq!(input.tools[0].id, "rust");
assert_eq!(input.tools[0].env[0].0, "CARGO_TERM_COLOR");
assert_eq!(input.env[0].name, "RUSTUP_HOME");
assert_eq!(input.files[0].kind.as_deref(), Some("file"));
}
#[test]
fn dto_plugins_event_subscription_requests_use_stable_camel_case_contract() {
let project_id = Uuid::from_u128(127).to_string();
let subscribe = PluginEventSubscribeDto {
project_id: project_id.clone(),
event_types: vec![
"workspaceFileChanged".to_owned(),
"backgroundTaskChanged".to_owned(),
],
capacity: Some(250),
};
assert_eq!(
serde_json::to_value(&subscribe).unwrap(),
json!({
"projectId": project_id,
"eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
"capacity": 250
})
);
let input: application::PluginEventSubscribeInput = subscribe.into();
assert_eq!(
input.event_types,
vec![
"workspaceFileChanged".to_owned(),
"backgroundTaskChanged".to_owned()
]
);
assert_eq!(input.capacity, Some(250));
let poll = PluginEventPollDto {
subscription_id: Uuid::from_u128(128).to_string(),
max_events: Some(50),
};
assert_eq!(
serde_json::to_value(&poll).unwrap(),
json!({
"subscriptionId": poll.subscription_id,
"maxEvents": 50
})
);
let input: application::PluginEventPollInput = poll.into();
assert_eq!(input.max_events, Some(50));
let unsubscribe = PluginEventUnsubscribeDto {
subscription_id: Uuid::from_u128(129).to_string(),
};
assert_eq!(
serde_json::to_value(&unsubscribe).unwrap(),
json!({ "subscriptionId": unsubscribe.subscription_id })
);
}
#[test]
fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
let project_id = ProjectId::from_uuid(Uuid::from_u128(1));