fix: align Companion REST DTO contracts

This commit is contained in:
2026-09-01 01:29:04 +09:00
parent 9756174676
commit a9ad42a970
10 changed files with 863 additions and 211 deletions
@@ -0,0 +1,3 @@
fn main() {
print!("{}", workspace_api::companion_api_typescript());
}
+290
View File
@@ -93,6 +93,7 @@ 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)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity {
Info,
@@ -101,6 +102,7 @@ pub enum DiagnosticSeverity {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Diagnostic {
pub code: String,
pub severity: DiagnosticSeverity,
@@ -325,6 +327,7 @@ pub struct WorkerCapabilitySummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkspaceWorkerSubject {
RuntimeWorker {
@@ -337,6 +340,7 @@ pub enum WorkspaceWorkerSubject {
/// Runtime placement appears only in the typed subject required by Worker
/// control operations; provider and launch internals are intentionally omitted.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceWorkerDiscoveryItem {
pub subject: WorkspaceWorkerSubject,
pub resource_key: String,
@@ -346,6 +350,137 @@ pub struct WorkspaceWorkerDiscoveryItem {
pub status: Option<String>,
}
/// Public lifecycle projection for the Workspace Companion endpoint.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionLifecycleState {
Idle,
Running,
Stopped,
}
/// Public outcome of a Companion message submission.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionMessageDisposition {
Accepted,
Rejected,
}
/// Public, bounded transport metadata for Companion status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTransportSummary {
pub mode: String,
pub available: bool,
}
/// Public Workspace Companion status response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionStatusResponse {
pub state: CompanionLifecycleState,
pub worker: Option<WorkspaceWorkerDiscoveryItem>,
pub transport: CompanionTransportSummary,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
/// Public Workspace Companion message request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionMessageRequest {
pub content: String,
}
/// Public Workspace Companion cancellation request.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionCancelRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// Public Workspace Companion message response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionMessageResponse {
pub state: CompanionMessageDisposition,
pub message: String,
}
/// User-visible role accepted in the public Companion transcript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompanionTranscriptRole {
User,
Assistant,
}
/// One allowlisted, user-visible Companion transcript item.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTranscriptItem {
pub sequence: usize,
pub role: CompanionTranscriptRole,
pub content: String,
pub created_at: String,
}
/// Bounded public Companion transcript projection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CompanionTranscriptProjection {
pub state: CompanionLifecycleState,
pub start: usize,
pub limit: usize,
pub total: usize,
pub next: Option<usize>,
pub items: Vec<CompanionTranscriptItem>,
}
#[cfg(feature = "typescript")]
pub fn companion_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
DiagnosticSeverity::decl(&config),
Diagnostic::decl(&config),
WorkspaceWorkerSubject::decl(&config),
WorkspaceWorkerDiscoveryItem::decl(&config),
CompanionLifecycleState::decl(&config),
CompanionMessageDisposition::decl(&config),
CompanionTransportSummary::decl(&config),
CompanionStatusResponse::decl(&config),
CompanionMessageRequest::decl(&config),
CompanionCancelRequest::decl(&config),
CompanionMessageResponse::decl(&config),
CompanionTranscriptRole::decl(&config),
CompanionTranscriptItem::decl(&config),
CompanionTranscriptProjection::decl(&config),
];
format!(
"// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkspaceWorkerDiscoveryPage {
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
@@ -560,4 +695,159 @@ mod tests {
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
}
fn round_trip<T>(value: T)
where
T: std::fmt::Debug + PartialEq + Serialize + for<'de> Deserialize<'de>,
{
let encoded = serde_json::to_vec(&value).expect("fixture should serialize");
let decoded: T = serde_json::from_slice(&encoded).expect("fixture should deserialize");
assert_eq!(decoded, value);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker {
runtime_id: "arcadia".to_string(),
worker_id: "worker-7".to_string(),
},
resource_key: "W-7".to_string(),
display_name: "Companion".to_string(),
profile: Some("builtin:companion".to_string()),
status: Some("idle".to_string()),
}
}
#[test]
fn companion_status_fixtures_round_trip() {
for state in [
CompanionLifecycleState::Idle,
CompanionLifecycleState::Running,
CompanionLifecycleState::Stopped,
] {
round_trip(CompanionStatusResponse {
state,
worker: Some(companion_worker()),
transport: CompanionTransportSummary {
mode: "worker_runtime".to_string(),
available: state != CompanionLifecycleState::Stopped,
},
diagnostics: Vec::new(),
});
}
}
#[test]
fn companion_message_fixtures_round_trip() {
for state in [
CompanionMessageDisposition::Accepted,
CompanionMessageDisposition::Rejected,
] {
round_trip(CompanionMessageResponse {
state,
message: if state == CompanionMessageDisposition::Accepted {
"accepted"
} else {
"rejected"
}
.to_string(),
});
}
}
#[test]
fn companion_transcript_fixture_round_trips() {
round_trip(CompanionTranscriptProjection {
state: CompanionLifecycleState::Idle,
start: 0,
limit: 2,
total: 2,
next: None,
items: vec![
CompanionTranscriptItem {
sequence: 1,
role: CompanionTranscriptRole::User,
content: "hello".to_string(),
created_at: "2026-08-31T00:00:00Z".to_string(),
},
CompanionTranscriptItem {
sequence: 2,
role: CompanionTranscriptRole::Assistant,
content: "hi".to_string(),
created_at: "2026-08-31T00:00:01Z".to_string(),
},
],
});
}
#[test]
fn companion_transcript_rejects_system_and_private_fields() {
let public_item = CompanionTranscriptItem {
sequence: 1,
role: CompanionTranscriptRole::Assistant,
content: "visible".to_string(),
created_at: "2026-08-31T00:00:00Z".to_string(),
};
let public_fields = serde_json::to_value(public_item)
.expect("public transcript item should serialize")
.as_object()
.expect("public transcript item should be an object")
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
public_fields,
["content", "created_at", "role", "sequence"]
.into_iter()
.map(str::to_string)
.collect()
);
let system_item = serde_json::json!({
"sequence": 1,
"role": "system",
"content": "raw system prompt",
"created_at": "2026-08-31T00:00:00Z"
});
assert!(serde_json::from_value::<CompanionTranscriptItem>(system_item).is_err());
let private_item = serde_json::json!({
"sequence": 1,
"role": "assistant",
"content": "visible",
"created_at": "2026-08-31T00:00:00Z",
"reasoning": "hidden",
"credential": "secret",
"provider_session_id": "session-private"
});
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_companion_api_contract_is_current() {
let expected = companion_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/companion-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
assert_eq!(
normalize_typescript(&actual),
normalize_typescript(&expected),
"regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file",
);
}
#[cfg(feature = "typescript")]
fn normalize_typescript(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect::<String>()
.replace(";}", "}")
}
}