feat: migrate runtime worker domains to ServerApi

This commit is contained in:
2026-09-23 08:24:39 +09:00
parent 7a975765ac
commit ad12d77ad4
28 changed files with 10269 additions and 896 deletions
@@ -0,0 +1,59 @@
use std::fs;
use std::path::Path;
use server_api::repository_openapi_typescript::{
INPUT_PATH, RUNTIME_OUTPUT_PATH, generate_runtime_typescript,
};
fn main() {
if let Err(error) = run() {
eprintln!("{error}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let mut check = false;
for argument in std::env::args().skip(1) {
match argument.as_str() {
"--check" => check = true,
"--help" | "-h" => {
println!(
"Generate the bounded Runtime API TypeScript artifact.\n\n\
Usage: cargo run -q -p server-api --example generate_runtime_api_types -- [--check]"
);
return Ok(());
}
other => return Err(format!("unknown argument `{other}`")),
}
}
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let input_path = root.join(INPUT_PATH);
let output_path = root.join(RUNTIME_OUTPUT_PATH);
let input = fs::read_to_string(&input_path)
.map_err(|error| format!("failed to read {}: {error}", input_path.display()))?;
let generated = generate_runtime_typescript(&input)
.map_err(|error| format!("failed to generate Runtime API types: {error}"))?;
if check {
let checked_in = fs::read_to_string(&output_path)
.map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
if checked_in != generated {
return Err(format!(
"{} is stale; regenerate it without --check",
output_path.display()
));
}
println!("{} is current", output_path.display());
return Ok(());
}
let temporary_path = output_path.with_extension("ts.tmp");
fs::write(&temporary_path, generated)
.map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?;
fs::rename(&temporary_path, &output_path)
.map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?;
println!("generated {}", output_path.display());
Ok(())
}
+1132 -124
View File
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,11 @@ pub const WORKDIR_GENERATOR_VERSION: &str = "1";
pub const WORKDIR_GENERATOR_OPTIONS: &str =
"workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output";
pub const WORKDIR_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/workdir-api.ts";
pub const RUNTIME_GENERATOR_NAME: &str = "yoi-runtime-openapi-typescript";
pub const RUNTIME_GENERATOR_VERSION: &str = "1";
pub const RUNTIME_GENERATOR_OPTIONS: &str =
"runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output";
pub const RUNTIME_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/runtime-api.ts";
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0;
@@ -119,6 +124,48 @@ pub fn generate_workdir_typescript(input: &str) -> Result<String, GenerationErro
Ok(output)
}
pub fn generate_runtime_typescript(input: &str) -> Result<String, GenerationError> {
let document: Value = serde_json::from_str(input)
.map_err(|error| GenerationError::invalid(format!("invalid OpenAPI JSON: {error}")))?;
let root = object(&document, "OpenAPI document")?;
if root.get("openapi").and_then(Value::as_str) != Some("3.1.0") {
return Err(GenerationError::invalid(
"Runtime TypeScript generation requires OpenAPI 3.1.0",
));
}
let info = object_field(root, "info", "OpenAPI document")?;
let contract_version = required_string(info, "version", "OpenAPI info")?;
let contract_digest = required_string(info, "x-yoi-source-digest", "OpenAPI info")?;
validate_contract_digest(contract_digest)?;
let schemas = object_field(
object_field(root, "components", "OpenAPI document")?,
"schemas",
"OpenAPI components",
)?;
let paths = object_field(root, "paths", "OpenAPI document")?;
let roots = validate_runtime_operations(paths)?;
let closure = schema_closure(schemas, &roots)?;
let mut output = format!(
"// @generated by {RUNTIME_GENERATOR_NAME}@{RUNTIME_GENERATOR_VERSION}. Do not edit.\n\
// Input: {INPUT_PATH}\n\
// Input contract: {contract_version} ({contract_digest})\n\
// Options: {RUNTIME_GENERATOR_OPTIONS}\n\
// Output: {RUNTIME_OUTPUT_PATH}\n\
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check\n\n"
);
for name in closure {
let schema = schemas
.get(&name)
.ok_or_else(|| GenerationError::invalid(format!("missing schema `{name}`")))?;
let declaration = render_schema(schema, &format!("schema `{name}`"))?;
output.push_str(&format!("export type {name} = {declaration};\n\n"));
}
output.pop();
Ok(output)
}
fn validate_contract_digest(digest: &str) -> Result<(), GenerationError> {
let Some(hex) = digest.strip_prefix("sha256:") else {
return Err(GenerationError::invalid(
@@ -347,6 +394,364 @@ fn validate_workdir_operations(
validate_operation_contracts(paths, &operations)
}
fn validate_runtime_operations(
paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> {
let operations = [
runtime_operation(
"/api/runtimes",
"get",
"runtime_list_alias",
None,
&[("200", "RuntimeListResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes",
"get",
"runtime_list",
None,
&[("200", "WorkspaceRuntimeListResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes",
"post",
"runtime_create",
Some("CreateRemoteRuntimeRequest"),
&[
("200", "WorkspaceRuntimeResource"),
("201", "WorkspaceRuntimeResource"),
],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}",
"get",
"runtime_detail",
None,
&[("200", "WorkspaceRuntimeDetail")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}",
"post",
"runtime_update",
Some("UpdateRemoteRuntimeRequest"),
&[("200", "WorkspaceRuntimeDetail")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}",
"delete",
"runtime_remove",
Some("RemoveRuntimeRequest"),
&[("200", "RuntimeRemovalOperationResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
"get",
"runtime_trust_key_reveal",
None,
&[("200", "RuntimeTrustKeyRevealResponse")],
),
RuntimeOperationContract {
error: "RuntimeManagementApiError",
..runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
"delete",
"runtime_trust_key_revoke",
Some("RevokeRuntimeTrustKeyRequest"),
&[("200", "WorkspaceRuntimeDetail")],
)
},
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
"post",
"runtime_connection_test",
None,
&[("200", "RuntimeConnectionTestResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers",
"get",
"runtime_worker_list_alias",
None,
&[("200", "RuntimeWorkerListResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers",
"post",
"runtime_worker_create_alias",
Some("RuntimeWorkerSpawnRequest"),
&[("200", "RuntimeWorkerSpawnResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers",
"get",
"runtime_worker_list",
None,
&[("200", "RuntimeWorkerListResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers",
"post",
"runtime_worker_create",
Some("RuntimeWorkerSpawnRequest"),
&[("200", "RuntimeWorkerSpawnResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/config-bundles",
"post",
"runtime_config_bundle_sync_alias",
Some("RuntimeConfigBundleSyncRequest"),
&[("200", "RuntimeConfigBundleResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles",
"post",
"runtime_config_bundle_sync",
Some("RuntimeConfigBundleSyncRequest"),
&[("200", "RuntimeConfigBundleResult")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
"get",
"runtime_config_bundle_availability_alias",
None,
&[("200", "RuntimeConfigBundleResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/config-bundles/{bundle_id}/availability",
"get",
"runtime_config_bundle_availability",
None,
&[("200", "RuntimeConfigBundleResult")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}",
"get",
"runtime_worker_detail_alias",
None,
&[("200", "RuntimeWorkerShowResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}",
"get",
"runtime_worker_detail",
None,
&[("200", "RuntimeWorkerShowResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}/restore",
"post",
"runtime_worker_restore_alias",
None,
&[("200", "WorkerRestoreResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore",
"post",
"runtime_worker_restore",
None,
&[("200", "WorkerRestoreResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin",
"put",
"runtime_worker_pin",
None,
&[("200", "WorkerRetentionResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/pin",
"delete",
"runtime_worker_unpin",
None,
&[("200", "WorkerRetentionResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan",
"get",
"runtime_cleanup_plan",
None,
&[("200", "RuntimeCleanupPlanResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-executions",
"post",
"runtime_cleanup_execute",
Some("ExecuteRuntimeCleanupRequest"),
&[("200", "RuntimeCleanupExecutionResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}/input",
"post",
"runtime_worker_input_alias",
Some("RuntimeWorkerInputRequest"),
&[("200", "RuntimeWorkerInputResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/input",
"post",
"runtime_worker_input",
Some("RuntimeWorkerInputRequest"),
&[("200", "RuntimeWorkerInputResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-upload-grants",
"post",
"runtime_worker_attachment_upload_grant",
None,
&[("200", "AttachmentUploadGrantResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachment-uploads/{upload_id}",
"delete",
"runtime_worker_attachment_upload_cancel",
None,
&[("200", "AttachmentUploadCancelResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/attachments/{artifact_id}",
"delete",
"runtime_worker_attachment_delete",
None,
&[("200", "WorkerFileDeleteResponse")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}/completions",
"post",
"runtime_worker_completions_alias",
Some("RuntimeWorkerCompletionsRequest"),
&[("200", "RuntimeWorkerCompletionsResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/completions",
"post",
"runtime_worker_completions",
Some("RuntimeWorkerCompletionsRequest"),
&[("200", "RuntimeWorkerCompletionsResult")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}/stop",
"post",
"runtime_worker_stop_alias",
Some("RuntimeWorkerLifecycleRequest"),
&[("200", "RuntimeWorkerLifecycleResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/stop",
"post",
"runtime_worker_stop",
Some("RuntimeWorkerLifecycleRequest"),
&[("200", "RuntimeWorkerLifecycleResult")],
),
runtime_operation(
"/api/runtimes/{runtime_id}/workers/{worker_id}/cancel",
"post",
"runtime_worker_cancel_alias",
Some("RuntimeWorkerLifecycleRequest"),
&[("200", "RuntimeWorkerLifecycleResult")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/cancel",
"post",
"runtime_worker_cancel",
Some("RuntimeWorkerLifecycleRequest"),
&[("200", "RuntimeWorkerLifecycleResult")],
),
];
validate_runtime_operation_contracts(paths, &operations)
}
const fn runtime_operation(
path: &'static str,
method: &'static str,
operation_id: &'static str,
request: Option<&'static str>,
successes: &'static [(&'static str, &'static str)],
) -> RuntimeOperationContract {
RuntimeOperationContract {
path,
method,
operation_id,
request,
successes,
error: "RepositoryApiError",
}
}
fn validate_runtime_operation_contracts(
paths: &Map<String, Value>,
operations: &[RuntimeOperationContract],
) -> Result<BTreeSet<String>, GenerationError> {
let mut roots = BTreeSet::new();
for operation in operations {
let path = object_field(paths, operation.path, "OpenAPI paths")?;
let value = object_field(path, operation.method, operation.path)?;
if value.get("operationId").and_then(Value::as_str) != Some(operation.operation_id) {
return Err(GenerationError::invalid(format!(
"{} {} must retain operationId `{}`",
operation.method, operation.path, operation.operation_id
)));
}
match operation.request {
Some(expected) => {
let request = object_field(value, "requestBody", operation.operation_id)?;
require_schema_ref(
json_content_schema(request, operation.operation_id)?,
expected,
operation.operation_id,
)?;
roots.insert(expected.to_owned());
}
None if value.contains_key("requestBody") => {
return Err(GenerationError::invalid(format!(
"{} unexpectedly gained a request body",
operation.operation_id
)));
}
None => {}
}
let responses = object_field(value, "responses", operation.operation_id)?;
let successes = operation
.successes
.iter()
.copied()
.collect::<BTreeMap<_, _>>();
for (status, response) in responses {
let response = object(
response,
&format!("{} response {status}", operation.operation_id),
)?;
let expected = successes
.get(status.as_str())
.copied()
.unwrap_or(operation.error);
require_schema_ref(
json_content_schema(response, operation.operation_id)?,
expected,
operation.operation_id,
)?;
roots.insert(expected.to_owned());
}
for (status, expected) in successes {
if !responses.contains_key(status) {
return Err(GenerationError::invalid(format!(
"{} is missing success response {status} ({expected})",
operation.operation_id
)));
}
}
}
Ok(roots)
}
struct RuntimeOperationContract {
path: &'static str,
method: &'static str,
operation_id: &'static str,
request: Option<&'static str>,
successes: &'static [(&'static str, &'static str)],
error: &'static str,
}
fn validate_operation_contracts(
paths: &Map<String, Value>,
operations: &[OperationContract],
@@ -686,6 +1091,21 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
.map(|branches| branches.join(" | "));
}
let disjoint_types = branches
.iter()
.map(|branch| branch.as_object()?.get("type")?.as_str())
.collect::<Option<Vec<_>>>();
if let Some(types) = disjoint_types {
let unique = types.iter().copied().collect::<BTreeSet<_>>();
if outer_type.is_none() && unique.len() == branches.len() {
return branches
.iter()
.map(|branch| render_schema(branch, context))
.collect::<Result<Vec<_>, _>>()
.map(|branches| branches.join(" | "));
}
}
let mut rendered = Vec::with_capacity(branches.len());
let mut seen_literals = BTreeSet::new();
for branch in branches {
@@ -1007,6 +1427,44 @@ mod tests {
const OPENAPI: &str = include_str!("../../../openapi/server-api.json");
const TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/repository-api.ts");
const RUNTIME_TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/runtime-api.ts");
#[test]
fn checked_in_runtime_typescript_is_current() {
assert_eq!(
generate_runtime_typescript(OPENAPI).unwrap(),
RUNTIME_TYPESCRIPT,
"regenerate with `cargo run -q -p server-api --example generate_runtime_api_types`",
);
}
#[test]
fn runtime_generator_validates_operation_ownership_and_schema_closure() {
let output = generate_runtime_typescript(OPENAPI).unwrap();
assert!(output.contains(&format!(
"@generated by {RUNTIME_GENERATOR_NAME}@{RUNTIME_GENERATOR_VERSION}"
)));
assert!(output.contains("export type RuntimeCleanupPlanResponse ="));
assert!(output.contains("export type RuntimeWorkerLifecycleResult ="));
assert!(output.contains("export type RuntimeWorkerSpawnAcceptanceRequirement = \"socket_ready\" | { run_accepted:"));
assert!(!output.contains("export type UnmigratedDomain ="));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/cleanup-plan"]["get"]["operationId"] =
serde_json::json!("manual_cleanup_plan");
let error = generate_runtime_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("runtime_cleanup_plan"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RuntimeWorkerSpawnAcceptanceRequirement"]
["oneOf"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"type": "object", "properties": {}, "additionalProperties": false}));
let error = generate_runtime_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("ambiguous oneOf"));
}
#[test]
fn checked_in_repository_typescript_is_current() {