feat(workspace): generate repository API contracts from OpenAPI

This commit is contained in:
2026-09-18 20:01:53 +09:00
parent 8435236862
commit 50ba3ab2d5
20 changed files with 1276 additions and 206 deletions
+1 -1
View File
@@ -15,13 +15,13 @@ protocol.workspace = true
runtime-api.workspace = true
schemars.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
ts-rs = { version = "12.0.1", optional = true }
webauthn-rs-proto = "0.5.5"
workdir.workspace = true
[dev-dependencies]
serde_json.workspace = true
tokio.workspace = true
[[example]]
@@ -0,0 +1,59 @@
use std::fs;
use std::path::Path;
use server_api::repository_openapi_typescript::{
INPUT_PATH, OUTPUT_PATH, generate_repository_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 Repository API TypeScript artifact.\n\n\
Usage: cargo run -q -p server-api --example generate_repository_openapi_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(OUTPUT_PATH);
let input = fs::read_to_string(&input_path)
.map_err(|error| format!("failed to read {}: {error}", input_path.display()))?;
let generated = generate_repository_typescript(&input)
.map_err(|error| format!("failed to generate Repository 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(())
}
+10 -13
View File
@@ -8,6 +8,7 @@ use api_macros::api;
pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, HttpMethod};
pub mod repository_openapi_typescript;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -3469,16 +3470,7 @@ pub fn legacy_catalog_typescript() -> String {
WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config),
WorkspaceProfileSourceProvenance::decl(&config),
RepositorySourceKind::decl(&config),
RepositorySource::decl(&config),
RepositoryObservedStatus::decl(&config),
RepositoryDiagnostic::decl(&config),
GitRemoteSummary::decl(&config),
GitRepositorySummary::decl(&config),
RepositorySummary::decl(&config),
GitCommitSummary::decl(&config),
RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config),
RepositorySshConnectionProbeRequest::decl(&config),
RepositorySshHostKeyCandidate::decl(&config),
RepositorySshConnectionTrustState::decl(&config),
@@ -3519,7 +3511,7 @@ pub fn legacy_catalog_typescript() -> String {
.map(|declaration| format!("export {declaration}"));
format!(
"// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n",
"// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\nimport type {{ RepositoryObservedStatus, RepositorySource }} from \"./repository-api\";\n\n{}\n",
declarations.join("\n\n")
)
}
@@ -4521,9 +4513,14 @@ mod tests {
);
assert!(output.contains("export type WorkspaceResponse ="));
assert!(output.contains("permissions: WorkspacePermissionSummary"));
assert!(output.contains("export type RepositoryListResponse ="));
assert!(output.contains("items: Array<RepositorySummary>"));
assert!(output.contains("observed_at?: string | null"));
assert!(output.contains(
"import type { RepositoryObservedStatus, RepositorySource } from \"./repository-api\";"
));
assert!(!output.contains("export type RepositoryListResponse ="));
assert!(!output.contains("export type RepositoryDetailResponse ="));
assert!(!output.contains("export type RepositorySummary ="));
assert!(!output.contains("export type RepositorySource ="));
assert!(!output.contains("export type RepositoryObservedStatus ="));
assert!(output.contains("export type WorkspaceMetadataSettingsResponse ="));
assert!(output.contains("export type WorkspaceMetadataMutationResponse ="));
assert!(output.contains("export type ProfileSettingsResponse ="));
@@ -0,0 +1,583 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Map, Value};
pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript";
pub const GENERATOR_VERSION: &str = "1";
pub const GENERATOR_OPTIONS: &str =
"repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output";
pub const INPUT_PATH: &str = "openapi/server-api.json";
pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-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;
const SCHEMA_REF_PREFIX: &str = "#/components/schemas/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerationError(String);
impl GenerationError {
fn invalid(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl std::fmt::Display for GenerationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for GenerationError {}
pub fn generate_repository_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(
"Repository 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_repository_operations(paths)?;
let closure = schema_closure(schemas, &roots)?;
let mut output = format!(
"// @generated by {GENERATOR_NAME}@{GENERATOR_VERSION}. Do not edit.\n\
// Input: {INPUT_PATH}\n\
// Input contract: {contract_version} ({contract_digest})\n\
// Options: {GENERATOR_OPTIONS}\n\
// Output: {OUTPUT_PATH}\n\
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_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(
"OpenAPI source digest must use sha256",
));
};
if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(GenerationError::invalid(
"OpenAPI source digest must contain 64 hexadecimal digits",
));
}
Ok(())
}
fn validate_repository_operations(
paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> {
let mut roots = BTreeSet::new();
for operation in [
OperationContract {
path: "/api/repositories",
method: "get",
operation_id: "repository_list_alias",
request: None,
successes: &[("200", "RepositoryListResponse")],
},
OperationContract {
path: "/api/repositories/{repository_key}",
method: "get",
operation_id: "repository_detail_alias",
request: None,
successes: &[("200", "RepositoryDetailResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/repositories",
method: "get",
operation_id: "repository_list",
request: None,
successes: &[("200", "RepositoryListResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/repositories",
method: "post",
operation_id: "repository_create",
request: Some("CreateWorkspaceRepositoryRequest"),
successes: &[
("200", "CreateWorkspaceRepositoryResponse"),
("201", "CreateWorkspaceRepositoryResponse"),
],
},
OperationContract {
path: "/api/w/{workspace_id}/repositories/{repository_key}",
method: "get",
operation_id: "repository_detail",
request: None,
successes: &[("200", "RepositoryDetailResponse")],
},
] {
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)?;
let schema = json_content_schema(request, operation.operation_id)?;
require_schema_ref(schema, 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 schema = json_content_schema(response, operation.operation_id)?;
let expected = successes
.get(status.as_str())
.copied()
.unwrap_or("RepositoryApiError");
require_schema_ref(schema, 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 OperationContract {
path: &'static str,
method: &'static str,
operation_id: &'static str,
request: Option<&'static str>,
successes: &'static [(&'static str, &'static str)],
}
fn json_content_schema<'a>(
value: &'a Map<String, Value>,
context: &str,
) -> Result<&'a Value, GenerationError> {
object_field(
object_field(value, "content", context)?,
"application/json",
context,
)?
.get("schema")
.ok_or_else(|| GenerationError::invalid(format!("{context} is missing its JSON schema")))
}
fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> {
let reference = object(value, context)?
.get("$ref")
.and_then(Value::as_str)
.ok_or_else(|| {
GenerationError::invalid(format!("{context} must use a schema reference"))
})?;
if reference != format!("{SCHEMA_REF_PREFIX}{expected}") {
return Err(GenerationError::invalid(format!(
"{context} must reference `{expected}`, found `{reference}`"
)));
}
Ok(())
}
fn schema_closure(
schemas: &Map<String, Value>,
roots: &BTreeSet<String>,
) -> Result<BTreeSet<String>, GenerationError> {
let mut closure = BTreeSet::new();
let mut pending = roots.iter().cloned().collect::<Vec<_>>();
while let Some(name) = pending.pop() {
if !closure.insert(name.clone()) {
continue;
}
let schema = schemas.get(&name).ok_or_else(|| {
GenerationError::invalid(format!("OpenAPI schema `{name}` is missing"))
})?;
let mut references = BTreeSet::new();
collect_schema_references(schema, &name, &mut references)?;
pending.extend(references);
}
Ok(closure)
}
fn collect_schema_references(
value: &Value,
context: &str,
references: &mut BTreeSet<String>,
) -> Result<(), GenerationError> {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref") {
let reference = reference.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-string $ref"))
})?;
let Some(name) = reference.strip_prefix(SCHEMA_REF_PREFIX) else {
return Err(GenerationError::invalid(format!(
"{context} contains unsupported reference `{reference}`"
)));
};
references.insert(name.to_owned());
}
for value in object.values() {
collect_schema_references(value, context, references)?;
}
}
Value::Array(values) => {
for value in values {
collect_schema_references(value, context, references)?;
}
}
_ => {}
}
Ok(())
}
fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
let schema = object(value, context)?;
if let Some(reference) = schema.get("$ref") {
let reference = reference.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-string $ref"))
})?;
return reference
.strip_prefix(SCHEMA_REF_PREFIX)
.map(str::to_owned)
.ok_or_else(|| {
GenerationError::invalid(format!(
"{context} contains unsupported reference `{reference}`"
))
});
}
if let Some(constant) = schema.get("const") {
return render_literal(constant, context);
}
if let Some(enumeration) = schema.get("enum") {
let values = enumeration.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array enum"))
})?;
if values.is_empty() {
return Err(GenerationError::invalid(format!(
"{context} contains an empty enum"
)));
}
return values
.iter()
.map(|value| render_literal(value, context))
.collect::<Result<Vec<_>, _>>()
.map(|values| values.join(" | "));
}
if let Some(branches) = schema.get("anyOf") {
return render_nullable_union(branches, context);
}
if let Some(branches) = schema.get("oneOf") {
let branches = branches.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array oneOf"))
})?;
if branches.is_empty() {
return Err(GenerationError::invalid(format!(
"{context} contains an empty oneOf"
)));
}
let mut rendered = Vec::with_capacity(branches.len());
for branch in branches {
let branch_object = object(branch, context)?;
if !branch_object.contains_key("enum") && !branch_object.contains_key("const") {
return Err(GenerationError::invalid(format!(
"{context} contains unsupported ambiguous oneOf semantics"
)));
}
rendered.push(render_schema(branch, context)?);
}
return Ok(rendered.join(" | "));
}
match schema.get("type") {
Some(Value::String(kind)) => render_typed_schema(kind, schema, context),
Some(Value::Array(kinds)) => {
if kinds.len() != 2 || !kinds.iter().any(|kind| kind.as_str() == Some("null")) {
return Err(GenerationError::invalid(format!(
"{context} contains an unsupported type union"
)));
}
let kind = kinds
.iter()
.find_map(|kind| {
let kind = kind.as_str()?;
(kind != "null").then_some(kind)
})
.ok_or_else(|| {
GenerationError::invalid(format!("{context} has no non-null type"))
})?;
Ok(format!(
"{} | null",
render_typed_schema(kind, schema, context)?
))
}
_ => Err(GenerationError::invalid(format!(
"{context} has no supported type"
))),
}
}
fn render_nullable_union(value: &Value, context: &str) -> Result<String, GenerationError> {
let branches = value
.as_array()
.ok_or_else(|| GenerationError::invalid(format!("{context} contains a non-array anyOf")))?;
if branches.len() != 2
|| branches
.iter()
.filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
.count()
!= 1
{
return Err(GenerationError::invalid(format!(
"{context} contains unsupported ambiguous anyOf semantics"
)));
}
let branch = branches
.iter()
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
.expect("validated one non-null branch");
Ok(format!("{} | null", render_schema(branch, context)?))
}
fn render_typed_schema(
kind: &str,
schema: &Map<String, Value>,
context: &str,
) -> Result<String, GenerationError> {
match kind {
"string" => Ok("string".to_owned()),
"boolean" => Ok("boolean".to_owned()),
"null" => Ok("null".to_owned()),
"integer" => {
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if minimum.is_none_or(|minimum| minimum < MIN_SAFE_INTEGER)
|| maximum.is_none_or(|maximum| maximum > MAX_SAFE_INTEGER)
{
return Err(GenerationError::invalid(format!(
"{context} integer range exceeds JavaScript safe integers"
)));
}
Ok("number".to_owned())
}
"array" => {
let items = schema.get("items").ok_or_else(|| {
GenerationError::invalid(format!("{context} array has no item schema"))
})?;
Ok(format!("Array<{}>", render_schema(items, context)?))
}
"object" => render_object(schema, context),
other => Err(GenerationError::invalid(format!(
"{context} uses unsupported type `{other}`"
))),
}
}
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
let properties = schema
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?;
let required = match schema.get("required") {
Some(Value::Array(required)) => required
.iter()
.map(|name| {
name.as_str().map(str::to_owned).ok_or_else(|| {
GenerationError::invalid(format!(
"{context} contains a non-string required property"
))
})
})
.collect::<Result<BTreeSet<_>, _>>()?,
None => BTreeSet::new(),
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} contains a non-array required declaration"
)));
}
};
if let Some(missing) = required.iter().find(|name| !properties.contains_key(*name)) {
return Err(GenerationError::invalid(format!(
"{context} requires missing property `{missing}`"
)));
}
if schema
.get("additionalProperties")
.is_some_and(|value| value != &Value::Bool(false))
{
return Err(GenerationError::invalid(format!(
"{context} contains unsupported additionalProperties"
)));
}
let mut fields = Vec::with_capacity(properties.len());
for (name, property) in properties {
let optional = if required.contains(name) { "" } else { "?" };
let name = if is_typescript_identifier(name) {
name.clone()
} else {
serde_json::to_string(name).expect("serializing a property name cannot fail")
};
fields.push(format!(
"{name}{optional}: {}",
render_schema(property, context)?
));
}
Ok(format!("{{ {}; }}", fields.join("; ")))
}
fn render_literal(value: &Value, context: &str) -> Result<String, GenerationError> {
match value {
Value::String(_) | Value::Bool(_) | Value::Null => {
serde_json::to_string(value).map_err(|error| {
GenerationError::invalid(format!("failed to render {context} literal: {error}"))
})
}
_ => Err(GenerationError::invalid(format!(
"{context} contains an unsupported non-string enum/const literal"
))),
}
}
fn is_typescript_identifier(value: &str) -> bool {
let mut bytes = value.bytes();
let Some(first) = bytes.next() else {
return false;
};
(first.is_ascii_alphabetic() || matches!(first, b'_' | b'$'))
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$'))
}
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, GenerationError> {
value
.as_object()
.ok_or_else(|| GenerationError::invalid(format!("{context} must be an object")))
}
fn object_field<'a>(
value: &'a Map<String, Value>,
field: &str,
context: &str,
) -> Result<&'a Map<String, Value>, GenerationError> {
value
.get(field)
.ok_or_else(|| GenerationError::invalid(format!("{context} is missing `{field}`")))
.and_then(|value| object(value, &format!("{context}.{field}")))
}
fn required_string<'a>(
value: &'a Map<String, Value>,
field: &str,
context: &str,
) -> Result<&'a str, GenerationError> {
value
.get(field)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| GenerationError::invalid(format!("{context} requires string `{field}`")))
}
#[cfg(test)]
mod tests {
use super::*;
const OPENAPI: &str = include_str!("../../../openapi/server-api.json");
const TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/repository-api.ts");
#[test]
fn checked_in_repository_typescript_is_current() {
assert_eq!(
generate_repository_typescript(OPENAPI).unwrap(),
TYPESCRIPT,
"regenerate with `cargo run -q -p server-api --example generate_repository_openapi_types`",
);
}
#[test]
fn output_records_authority_and_preserves_openapi_nullability() {
let output = generate_repository_typescript(OPENAPI).unwrap();
assert!(output.contains(&format!(
"@generated by {GENERATOR_NAME}@{GENERATOR_VERSION}"
)));
assert!(output.contains("Input contract: 0.1.0 (sha256:"));
assert!(output.contains("export type RepositorySourceKind = \"local_path\" | \"file\" | \"ssh\" | \"https\" | \"invalid\""));
assert!(output.contains("git?: GitRepositorySummary | null"));
assert!(output.contains("default_ref?: string | null"));
assert!(!output.contains("export type string ="));
}
#[test]
fn generator_rejects_lossy_safe_integer_and_union_changes() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe integers"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"type": "string"}));
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("ambiguous anyOf"));
}
#[test]
fn generator_is_bounded_to_the_repository_operation_contract() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["UnmigratedDomain"] = serde_json::json!({
"type": "object",
"properties": {"pretend": {"type": "string"}},
"required": ["pretend"],
"additionalProperties": false
});
let output = generate_repository_typescript(&document.to_string()).unwrap();
assert!(!output.contains("UnmigratedDomain"));
}
}
+4 -2
View File
@@ -6,10 +6,12 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
"preview": "deno run -A npm:vite@7.2.7 preview",
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check"
},
"imports": {
"$lib/": "./src/lib/",
+14 -13
View File
@@ -1,19 +1,20 @@
# Generated TypeScript inventory
# Generated frontend contracts
`server-api` is the Rust source authority for the checked-in Server HTTP DTO projections in this directory.
Regenerate them from the repository root with:
Checked-in files in this directory are generated API contracts. Do not edit them by hand.
## Repository list/detail/create API
`repository-api.ts` is the only TypeScript declaration authority for the Repository list, detail, and create request/response/error wire types. It is generated from the schema closure of the five Repository operations in the canonical `openapi/server-api.json` artifact. The generated header pins the in-repository generator name/version/options, canonical input path and digest, and output path.
Regenerate or verify it from the repository root:
```sh
cargo run -q -p server-api --features typescript --example generate_legacy_typescript > web/workspace/src/lib/generated/legacy-server-api.ts
cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts
cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts
cargo run -q -p server-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts
cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts
cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts
cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts
cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts
cargo run -q -p server-api --example generate_repository_openapi_types
cargo run -q -p server-api --example generate_repository_openapi_types -- --check
```
`legacy-server-api.ts` is the bounded aggregate projection retained during the OpenAPI migration. It is not the final OpenAPI-derived Frontend artifact and must not become a second contract authority. The other files are narrower projections generated from the same Rust source.
The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, unsupported enum literals, and integer ranges that cannot be represented safely by JavaScript numbers. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types.
`ticket-api.ts` and `protocol.ts` are owned by the separate `ticket` and `protocol` authorities respectively.
## Other generated contracts
The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it.
@@ -1,6 +1,8 @@
// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.
// Do not edit this file directly.
import type { RepositoryObservedStatus, RepositorySource } from "./repository-api";
export type WorkspaceSummary = { workspace_id: string, owner_account_id: string, display_name: string, state: string, created_at: string, updated_at: string, };
export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
@@ -63,31 +65,8 @@ export type WorkspaceProfileSourceSummary = { profile_source_id: string, display
export type WorkspaceProfileSourceProvenance = "project_profile_source_tree";
export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid";
export type RepositorySource = { kind: RepositorySourceKind,
/**
* Canonical source representation. This is an absolute local path for
* `local_path`, and a normalized URI/remote specification otherwise.
*/
uri: string, };
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
export type RepositoryDiagnostic = { severity: string, code: string, message: string, };
export type GitRemoteSummary = { name: string, fetch_url: string, };
export type GitRepositorySummary = { status: string, head: string | null, branch: string | null, dirty: boolean, remotes: Array<GitRemoteSummary>, };
export type RepositorySummary = { repository_key: string, kind: string, provider: string, source: RepositorySource, source_revision: number, source_fingerprint: string, observed_status: RepositoryObservedStatus, observed_at?: string | null, default_selector?: string | null, record_authority: string, git?: GitRepositorySummary | null, diagnostics?: Array<RepositoryDiagnostic> | null, };
export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array<string>, refs: Array<string>, };
export type RepositoryListResponse = { workspace_id: string, items: Array<RepositorySummary>, source: string, diagnostics: Array<Diagnostic>, };
export type RepositoryDetailResponse = { workspace_id: string, item: RepositorySummary, source: string, };
export type RepositorySshConnectionProbeRequest = { runtime_id: string, };
export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, };
@@ -0,0 +1,34 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef)
// Options: repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; };
export type CreateWorkspaceRepositoryResponse = { replayed: boolean; repository_key: string; workspace_id: string; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
export type DiagnosticSeverity = "info" | "warning" | "error";
export type GitRemoteSummary = { fetch_url: string; name: string; };
export type GitRepositorySummary = { branch?: string | null; dirty: boolean; head?: string | null; remotes: Array<GitRemoteSummary>; status: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; };
export type RepositoryDiagnostic = { code: string; message: string; severity: string; };
export type RepositoryListResponse = { diagnostics: Array<Diagnostic>; items: Array<RepositorySummary>; source: string; workspace_id: string; };
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
export type RepositorySource = { kind: RepositorySourceKind; uri: string; };
export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid";
export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array<RepositoryDiagnostic> | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; };
+2 -2
View File
@@ -16,7 +16,7 @@ export type ApiResult<T> = {
export type { SkillCatalogResponse, SkillDetailResponse };
type JsonLoadPolicy = {
export type JsonLoadPolicy = {
diagnosticLabel: string;
maxResponseBytes: number;
};
@@ -133,7 +133,7 @@ export async function loadJson<T>(
}
}
async function readBoundedJson(
export async function readBoundedJson(
response: Response,
maxBytes: number,
): Promise<unknown> {
@@ -0,0 +1,102 @@
import type {
CreateWorkspaceRepositoryRequest,
CreateWorkspaceRepositoryResponse,
RepositoryDetailResponse,
RepositoryListResponse,
} from "$lib/generated/repository-api.ts";
import {
type ApiResult,
loadJson,
readBoundedJson,
workspaceApiPath,
} from "$lib/workspace/api/http.ts";
import {
parseCreateWorkspaceRepositoryResponse,
parseRepositoryApiError,
parseRepositoryDetailResponse,
parseRepositoryListResponse,
} from "$lib/workspace/api/workspace-model.ts";
export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const REPOSITORY_API_LOAD_POLICY = {
diagnosticLabel: "Repository API",
maxResponseBytes: REPOSITORY_API_MAX_RESPONSE_BYTES,
} as const;
export function workspaceRepositoryListPath(workspaceId: string): string {
return workspaceApiPath(workspaceId, "/repositories");
}
export function workspaceRepositoryDetailPath(
workspaceId: string,
repositoryKey: string,
): string {
return workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryKey)}`,
);
}
export async function loadWorkspaceRepositoryList(
fetchFn: typeof fetch,
workspaceId: string,
): Promise<ApiResult<RepositoryListResponse>> {
return loadJson(
fetchFn,
workspaceRepositoryListPath(workspaceId),
undefined,
parseRepositoryListResponse,
REPOSITORY_API_LOAD_POLICY,
);
}
export async function loadWorkspaceRepositoryDetail(
fetchFn: typeof fetch,
workspaceId: string,
repositoryKey: string,
): Promise<ApiResult<RepositoryDetailResponse>> {
return loadJson(
fetchFn,
workspaceRepositoryDetailPath(workspaceId, repositoryKey),
undefined,
parseRepositoryDetailResponse,
REPOSITORY_API_LOAD_POLICY,
);
}
export async function createWorkspaceRepository(
fetchFn: typeof fetch,
workspaceId: string,
request: CreateWorkspaceRepositoryRequest,
): Promise<ApiResult<CreateWorkspaceRepositoryResponse>> {
const path = workspaceRepositoryListPath(workspaceId);
try {
const response = await fetchFn(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
const payload = await readBoundedJson(
response,
REPOSITORY_API_MAX_RESPONSE_BYTES,
);
if (!response.ok) {
try {
const error = parseRepositoryApiError(payload);
return { data: null, error: error.message };
} catch {
return {
data: null,
error: `Repository API request failed with HTTP ${response.status}`,
};
}
}
return {
data: parseCreateWorkspaceRepositoryResponse(payload),
error: null,
};
} catch {
return { data: null, error: "Repository API response is invalid" };
}
}
@@ -1,11 +1,11 @@
import {
parseRepositoryListResponse,
parseWorkspaceCatalogResponse,
parseWorkspaceCreateResponse,
type RepositorySummary,
type WorkspaceCreateResponse,
type WorkspaceSummary,
} from "$lib/workspace/api/workspace-model";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts";
export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
@@ -48,12 +48,14 @@ export async function listWorkspaceRepositories(
fetcher: Fetch,
workspaceId: string,
): Promise<RepositorySummary[]> {
return parseRepositoryListResponse(
await fetchJson(
fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
),
).items;
const result = await loadWorkspaceRepositoryList(fetcher, workspaceId);
if (result.data === null) {
throw new WorkspaceCatalogError(
null,
result.error ?? "Repository API request failed",
);
}
return result.data.items;
}
export async function loadWorkspaceCatalog(
@@ -1,19 +1,10 @@
import type { ApiResult } from "$lib/workspace/api/http";
import type {
Diagnostic,
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryDiagnostic,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySource,
RepositorySourceKind,
RepositorySshConnectionProbeResponse,
RepositorySshConnectionTrustState,
RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
@@ -30,17 +21,25 @@ import type {
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts";
import type {
CreateWorkspaceRepositoryResponse,
Diagnostic,
GitRemoteSummary,
GitRepositorySummary,
RepositoryApiError,
RepositoryDetailResponse,
RepositoryDiagnostic,
RepositoryListResponse,
RepositorySource,
RepositorySourceKind,
RepositorySummary,
} from "$lib/generated/repository-api.ts";
export type {
GitCommitSummary,
GitRemoteSummary,
GitRepositorySummary,
RepositoryDetailResponse,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySshConnectionProbeResponse,
RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceDeletionOperationResponse,
@@ -49,6 +48,18 @@ export type {
WorkspaceResponse,
WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts";
export type {
CreateWorkspaceRepositoryRequest,
CreateWorkspaceRepositoryResponse,
GitRemoteSummary,
GitRepositorySummary,
RepositoryApiError,
RepositoryDetailResponse,
RepositoryListResponse,
RepositorySource,
RepositorySourceKind,
RepositorySummary,
} from "$lib/generated/repository-api.ts";
type JsonObject = Record<string, unknown>;
@@ -62,6 +73,11 @@ const SOURCE_KINDS = new Set<RepositorySourceKind>([
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
export const REPOSITORY_API_LIMITS = {
maxStringCodeUnits: 65_536,
maxCollectionEntries: 4_096,
} as const;
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${path} must be an object`);
@@ -95,6 +111,26 @@ function nullableString(value: unknown, path: string): string | null {
return value === null ? null : string(value, path);
}
function repositoryString(value: unknown, path: string): string {
const parsed = string(value, path);
if (parsed.length > REPOSITORY_API_LIMITS.maxStringCodeUnits) {
throw new Error(`${path} exceeds the Repository API string limit`);
}
return parsed;
}
function nullableRepositoryString(value: unknown, path: string): string | null {
return value === null ? null : repositoryString(value, path);
}
function repositoryArray(value: unknown, path: string): unknown[] {
const parsed = array(value, path);
if (parsed.length > REPOSITORY_API_LIMITS.maxCollectionEntries) {
throw new Error(`${path} exceeds the Repository API collection limit`);
}
return parsed;
}
function optionalNullableString(
value: unknown,
path: string,
@@ -102,6 +138,15 @@ function optionalNullableString(
return value === undefined ? undefined : nullableString(value, path);
}
function optionalNullableRepositoryString(
value: unknown,
path: string,
): string | null | undefined {
return value === undefined
? undefined
: nullableRepositoryString(value, path);
}
function exactKeys(
value: JsonObject,
keys: readonly string[],
@@ -128,6 +173,20 @@ function diagnostic(value: unknown, path: string): Diagnostic {
};
}
function repositoryApiDiagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], path);
const severity = repositoryString(item.severity, `${path}.severity`);
if (!DIAGNOSTIC_SEVERITIES.has(severity)) {
throw new Error(`${path}.severity is invalid`);
}
return {
code: repositoryString(item.code, `${path}.code`),
severity: severity as Diagnostic["severity"],
message: repositoryString(item.message, `${path}.message`),
};
}
function repositoryDiagnostic(
value: unknown,
path: string,
@@ -135,22 +194,22 @@ function repositoryDiagnostic(
const item = object(value, path);
exactKeys(item, ["severity", "code", "message"], path);
return {
severity: string(item.severity, `${path}.severity`),
code: string(item.code, `${path}.code`),
message: string(item.message, `${path}.message`),
severity: repositoryString(item.severity, `${path}.severity`),
code: repositoryString(item.code, `${path}.code`),
message: repositoryString(item.message, `${path}.message`),
};
}
function repositorySource(value: unknown, path: string): RepositorySource {
const source = object(value, path);
exactKeys(source, ["kind", "uri"], path);
const kind = string(source.kind, `${path}.kind`);
const kind = repositoryString(source.kind, `${path}.kind`);
if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
throw new Error(`${path}.kind is invalid`);
}
return {
kind: kind as RepositorySourceKind,
uri: string(source.uri, `${path}.uri`),
uri: repositoryString(source.uri, `${path}.uri`),
};
}
@@ -158,8 +217,8 @@ function gitRemote(value: unknown, path: string): GitRemoteSummary {
const remote = object(value, path);
exactKeys(remote, ["name", "fetch_url"], path);
return {
name: string(remote.name, `${path}.name`),
fetch_url: string(remote.fetch_url, `${path}.fetch_url`),
name: repositoryString(remote.name, `${path}.name`),
fetch_url: repositoryString(remote.fetch_url, `${path}.fetch_url`),
};
}
@@ -167,13 +226,14 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary {
const git = object(value, path);
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
return {
status: string(git.status, `${path}.status`),
head: nullableString(git.head, `${path}.head`),
branch: nullableString(git.branch, `${path}.branch`),
status: repositoryString(git.status, `${path}.status`),
head: nullableRepositoryString(git.head, `${path}.head`),
branch: nullableRepositoryString(git.branch, `${path}.branch`),
dirty: boolean(git.dirty, `${path}.dirty`),
remotes: array(git.remotes, `${path}.remotes`).map((item, index) =>
gitRemote(item, `${path}.remotes[${index}]`)
),
remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
item,
index,
) => gitRemote(item, `${path}.remotes[${index}]`)),
};
}
@@ -197,7 +257,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
],
path,
);
const observedStatus = string(
const observedStatus = repositoryString(
item.observed_status,
`${path}.observed_status`,
);
@@ -207,29 +267,36 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
const diagnostics =
item.diagnostics === undefined || item.diagnostics === null
? item.diagnostics
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) =>
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
);
: repositoryArray(item.diagnostics, `${path}.diagnostics`).map((
entry,
index,
) => repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`));
return {
repository_key: string(item.repository_key, `${path}.repository_key`),
kind: string(item.kind, `${path}.kind`),
provider: string(item.provider, `${path}.provider`),
repository_key: repositoryString(
item.repository_key,
`${path}.repository_key`,
),
kind: repositoryString(item.kind, `${path}.kind`),
provider: repositoryString(item.provider, `${path}.provider`),
source: repositorySource(item.source, `${path}.source`),
source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string(
source_fingerprint: repositoryString(
item.source_fingerprint,
`${path}.source_fingerprint`,
),
observed_status: observedStatus as RepositorySummary["observed_status"],
observed_at: optionalNullableString(
observed_at: optionalNullableRepositoryString(
item.observed_at,
`${path}.observed_at`,
),
default_selector: optionalNullableString(
default_selector: optionalNullableRepositoryString(
item.default_selector,
`${path}.default_selector`,
),
record_authority: string(item.record_authority, `${path}.record_authority`),
record_authority: repositoryString(
item.record_authority,
`${path}.record_authority`,
),
git: item.git === undefined || item.git === null
? item.git
: gitSummary(item.git, `${path}.git`),
@@ -287,7 +354,7 @@ function workspaceRepositoryRecord(
],
path,
);
const observedStatus = string(
const observedStatus = repositoryString(
item.observed_status,
`${path}.observed_status`,
);
@@ -302,7 +369,7 @@ function workspaceRepositoryRecord(
source: repositorySource(item.source, `${path}.source`),
default_ref: nullableString(item.default_ref, `${path}.default_ref`),
source_revision: integer(item.source_revision, `${path}.source_revision`),
source_fingerprint: string(
source_fingerprint: repositoryString(
item.source_fingerprint,
`${path}.source_fingerprint`,
),
@@ -533,21 +600,28 @@ export function parseRepositoryListResponse(
"repository list response",
);
return {
workspace_id: string(
workspace_id: repositoryString(
response.workspace_id,
"repository list response.workspace_id",
),
items: array(response.items, "repository list response.items").map((
item,
index,
) => repositorySummary(item, `repository list response.items[${index}]`)),
source: string(response.source, "repository list response.source"),
diagnostics: array(
items: repositoryArray(response.items, "repository list response.items")
.map((
item,
index,
) => repositorySummary(item, `repository list response.items[${index}]`)),
source: repositoryString(
response.source,
"repository list response.source",
),
diagnostics: repositoryArray(
response.diagnostics,
"repository list response.diagnostics",
).map(
(item, index) =>
diagnostic(item, `repository list response.diagnostics[${index}]`),
repositoryApiDiagnostic(
item,
`repository list response.diagnostics[${index}]`,
),
),
};
}
@@ -578,12 +652,64 @@ export function parseRepositoryDetailResponse(
"repository detail response",
);
return {
workspace_id: string(
workspace_id: repositoryString(
response.workspace_id,
"repository detail response.workspace_id",
),
item: repositorySummary(response.item, "repository detail response.item"),
source: string(response.source, "repository detail response.source"),
source: repositoryString(
response.source,
"repository detail response.source",
),
};
}
export function parseCreateWorkspaceRepositoryResponse(
value: unknown,
): CreateWorkspaceRepositoryResponse {
const response = object(value, "repository create response");
exactKeys(
response,
["replayed", "repository_key", "workspace_id"],
"repository create response",
);
return {
replayed: boolean(response.replayed, "repository create response.replayed"),
repository_key: repositoryString(
response.repository_key,
"repository create response.repository_key",
),
workspace_id: repositoryString(
response.workspace_id,
"repository create response.workspace_id",
),
};
}
export function parseRepositoryApiError(value: unknown): RepositoryApiError {
const response = object(value, "repository error response");
exactKeys(
response,
["diagnostics", "error", "message"],
"repository error response",
);
return {
diagnostics: response.diagnostics === undefined
? undefined
: repositoryArray(
response.diagnostics,
"repository error response.diagnostics",
).map((item, index) =>
repositoryApiDiagnostic(
item,
`repository error response.diagnostics[${index}]`,
)
),
error: repositoryString(response.error, "repository error response.error"),
message: repositoryString(
response.message,
"repository error response.message",
),
};
}
@@ -1,16 +1,14 @@
import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseRepositoryListResponse,
parseWorkspaceResponse,
} from "$lib/workspace/api/workspace-model";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [workspaceResult, repositoryResult] = await Promise.all([
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
let workspace = null;
@@ -30,17 +28,8 @@ export const load: LayoutLoad = async ({ fetch, params }) => {
});
}
let repositories = null;
let repositoriesError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repositories = parseRepositoryListResponse(repositoryResult.data);
} catch (cause) {
repositoriesError = cause instanceof Error
? cause.message
: "invalid repository list response";
}
}
const repositories = repositoryResult.data;
const repositoriesError = repositoryResult.error;
return {
workspace,
@@ -1,22 +1,14 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { loadWorkspaceRepositoryDetail } from "$lib/workspace/api/repositories";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import {
parseRepositoryDetailResponse,
parseRepositoryLogResponse,
} from "$lib/workspace/api/workspace-model";
import { parseRepositoryLogResponse } from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const repositoryKey = params.repositoryKey;
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
loadJson<unknown>(
fetch,
workspaceApiPath(
workspaceId,
`/repositories/${encodeURIComponent(repositoryKey)}`,
),
),
loadWorkspaceRepositoryDetail(fetch, workspaceId, repositoryKey),
loadJson<unknown>(
fetch,
workspaceApiPath(
@@ -30,17 +22,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
]);
let repository = null;
let repositoryError = repositoryResult.error;
if (repositoryResult.data !== null) {
try {
repository = parseRepositoryDetailResponse(repositoryResult.data);
} catch (cause) {
repositoryError = cause instanceof Error
? cause.message
: "invalid repository detail response";
}
}
const repository = repositoryResult.data;
const repositoryError = repositoryResult.error;
let log = null;
let logError = logResult.error;
@@ -1,7 +1,11 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { workspaceApiPath, workspaceRoute } from '$lib/workspace/api/http';
import type { RepositorySourceKind } from '$lib/generated/legacy-server-api';
import { workspaceRoute } from '$lib/workspace/api/http';
import { createWorkspaceRepository } from '$lib/workspace/api/repositories';
import type {
CreateWorkspaceRepositoryRequest,
RepositorySourceKind,
} from '$lib/generated/repository-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -22,38 +26,27 @@
let pending = $state(false);
let requestError = $state<string | null>(null);
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
}
async function addRepository(event: SubmitEvent): Promise<void> {
event.preventDefault();
pending = true;
requestError = null;
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/repositories'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repository_key: repositoryKey,
source,
default_ref: defaultRef || null,
}),
});
if (!response.ok) throw new Error(await responseError(response));
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
const request: CreateWorkspaceRepositoryRequest = {
repository_key: repositoryKey,
source,
default_ref: defaultRef || null,
};
const result = await createWorkspaceRepository(fetch, data.workspaceId, request);
if (result.data === null) {
requestError = result.error ?? 'Repository creation failed';
pending = false;
return;
}
repositoryKey = '';
source = '';
defaultRef = '';
showAddRepository = false;
await invalidateAll();
pending = false;
}
</script>
@@ -1,13 +1,10 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListResponse } from "$lib/workspace/api/workspace-model";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const repositories = await loadJson(
const repositories = await loadWorkspaceRepositoryList(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
undefined,
parseRepositoryListResponse,
params.workspaceId,
);
return {
workspaceId: params.workspaceId,
@@ -1,6 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import {
canonicalResourceReference,
resourceKey,
@@ -17,10 +17,7 @@ export const load = (async ({ fetch, params }) => {
);
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson<unknown>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
@@ -40,7 +37,7 @@ export const load = (async ({ fetch, params }) => {
);
}
}
const repositories = parseRepositoryListApiResult(repositoriesRaw);
const repositories = repositoriesRaw;
return {
workspaceId: params.workspaceId,
+25 -15
View File
@@ -20,29 +20,39 @@ const loadSource = await Deno.readTextFile(
),
);
const apiSource = await Deno.readTextFile(
new URL("../../src/lib/workspace/api/repositories.ts", import.meta.url),
);
test("Repository settings use the scoped list and typed create collection", () => {
for (const token of [
"workspaceApiPath(params.workspaceId, \"/repositories\")",
"workspaceApiPath(data.workspaceId, '/repositories')",
"method: 'POST'",
"repository_key: repositoryKey",
"default_ref: defaultRef || null",
"await invalidateAll()",
]) {
const source = `${pageSource}\n${loadSource}\n${apiSource}`;
for (
const token of [
"loadWorkspaceRepositoryList",
"createWorkspaceRepository",
"workspaceRepositoryListPath(workspaceId)",
'method: "POST"',
"repository_key: repositoryKey",
"default_ref: defaultRef || null",
"await invalidateAll()",
]
) {
assert(
pageSource.includes(token) || loadSource.includes(token),
source.includes(token),
`Repository settings should include ${token}`,
);
}
});
test("Repository Add form keeps access secrets outside registration input", () => {
for (const forbidden of [
"private_key",
"passphrase",
"credential_id",
"host_trust_id",
]) {
for (
const forbidden of [
"private_key",
"passphrase",
"credential_id",
"host_trust_id",
]
) {
assert(
!pageSource.includes(forbidden),
`Repository registration must not accept ${forbidden}`,
+136
View File
@@ -0,0 +1,136 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
createWorkspaceRepository,
loadWorkspaceRepositoryList,
REPOSITORY_API_MAX_RESPONSE_BYTES,
} from "../src/lib/workspace/api/repositories.ts";
const repository = {
repository_key: "main",
kind: "git",
provider: "git",
source: { kind: "local_path", uri: "/srv/main" },
source_revision: 1,
source_fingerprint: "sha256:main",
observed_status: "ready",
record_authority: "workspace-control-plane",
};
Deno.test("Repository API loaders parse unknown JSON through the strict generated contract", async () => {
const valid = await loadWorkspaceRepositoryList(
() =>
Promise.resolve(
Response.json({
workspace_id: "workspace-a",
items: [repository],
source: "workspace-control-plane",
diagnostics: [],
}),
),
"workspace-a",
);
if (valid.data?.items[0]?.repository_key !== "main") {
throw new Error(`unexpected load result: ${valid.error}`);
}
const invalid = await loadWorkspaceRepositoryList(
() =>
Promise.resolve(
Response.json({
workspace_id: "workspace-a",
items: [{ ...repository, observed_status: "future_status" }],
source: "workspace-control-plane",
diagnostics: [],
}),
),
"workspace-a",
);
if (
invalid.data !== null ||
!invalid.error?.includes("Repository API response is invalid")
) {
throw new Error("unknown enum values must fail closed in the loader");
}
});
Deno.test("Repository create client validates request, response, and typed API errors", async () => {
const success = await createWorkspaceRepository(
(_input, init) => {
const request: unknown = JSON.parse(String(init?.body));
if (
typeof request !== "object" || request === null ||
!("repository_key" in request) || request.repository_key !== "main"
) {
throw new Error("typed create request was not serialized");
}
return Promise.resolve(
Response.json({
workspace_id: "workspace-a",
repository_key: "main",
replayed: false,
}),
);
},
"workspace-a",
{ repository_key: "main", source: "/srv/main", default_ref: null },
);
if (success.data?.repository_key !== "main" || success.data.replayed) {
throw new Error(`unexpected create result: ${success.error}`);
}
const rejected = await createWorkspaceRepository(
() =>
Promise.resolve(
Response.json(
{ error: "invalid_source", message: "source is invalid" },
{ status: 400 },
),
),
"workspace-a",
{ repository_key: "main", source: "bad", default_ref: null },
);
if (rejected.data !== null || rejected.error !== "source is invalid") {
throw new Error("typed RepositoryApiError message was not preserved");
}
const invalid = await createWorkspaceRepository(
() =>
Promise.resolve(
Response.json({
workspace_id: "workspace-a",
repository_key: "main",
replayed: null,
}),
),
"workspace-a",
{ repository_key: "main", source: "/srv/main", default_ref: null },
);
if (
invalid.data !== null ||
invalid.error !== "Repository API response is invalid"
) {
throw new Error("invalid create success payload must fail closed");
}
});
Deno.test("Repository API clients bound response bodies before parsing JSON", async () => {
const result = await loadWorkspaceRepositoryList(
() =>
Promise.resolve(
new Response("{}", {
headers: {
"content-length": String(REPOSITORY_API_MAX_RESPONSE_BYTES + 1),
},
}),
),
"workspace-a",
);
if (
result.data !== null || !result.error?.includes("exceeds its byte limit")
) {
throw new Error(`oversized response was not rejected: ${result.error}`);
}
});
+82 -2
View File
@@ -4,11 +4,14 @@ declare const Deno: {
};
import {
parseCreateWorkspaceRepositoryResponse,
parseRepositoryApiError,
parseRepositoryListApiResult,
parseRepositoryListResponse,
parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse,
parseWorkspaceResponse,
REPOSITORY_API_LIMITS,
} from "../src/lib/workspace/api/workspace-model.ts";
function assertThrows(operation: () => unknown, expected: string): void {
@@ -71,6 +74,63 @@ Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
);
});
Deno.test("repository create/error responses use the generated strict contract", () => {
const created = parseCreateWorkspaceRepositoryResponse({
workspace_id: repositoryList.workspace_id,
repository_key: "main",
replayed: false,
});
if (created.replayed || created.repository_key !== "main") {
throw new Error("create response fields were not preserved");
}
const error = parseRepositoryApiError({
error: "invalid_request",
message: "source is invalid",
});
if (error.error !== "invalid_request") {
throw new Error("error response fields were not preserved");
}
assertThrows(
() =>
parseCreateWorkspaceRepositoryResponse({
workspace_id: repositoryList.workspace_id,
repository_key: "main",
replayed: null,
}),
".replayed must be a boolean",
);
assertThrows(
() =>
parseRepositoryApiError({
error: "invalid_request",
message: "source is invalid",
context: {},
}),
".context is not part",
);
});
Deno.test("repository responses retain bounded strings and collections", () => {
const oversizedString = structuredClone(repositoryList);
oversizedString.items[0].provider = "x".repeat(
REPOSITORY_API_LIMITS.maxStringCodeUnits + 1,
);
assertThrows(
() => parseRepositoryListResponse(oversizedString),
"string limit",
);
const oversizedCollection = structuredClone(repositoryList);
oversizedCollection.items = Array.from(
{ length: REPOSITORY_API_LIMITS.maxCollectionEntries + 1 },
() => repositoryList.items[0],
);
assertThrows(
() => parseRepositoryListResponse(oversizedCollection),
"collection limit",
);
});
Deno.test("repository API result converts stale payloads into bounded page errors", () => {
const result = parseRepositoryListApiResult({
data: {
@@ -227,7 +287,7 @@ Deno.test("Workspace settings exposes owner-gated typed destructive confirmation
});
Deno.test("Repository settings consume the validated shared wire shape", async () => {
const [loadSource, pageSource] = await Promise.all([
const [loadSource, pageSource, apiSource, modelSource] = await Promise.all([
Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
@@ -240,21 +300,41 @@ Deno.test("Repository settings consume the validated shared wire shape", async (
import.meta.url,
),
),
Deno.readTextFile(
new URL("../src/lib/workspace/api/repositories.ts", import.meta.url),
),
Deno.readTextFile(
new URL("../src/lib/workspace/api/workspace-model.ts", import.meta.url),
),
]);
const targetSources = `${loadSource}\n${pageSource}\n${apiSource}`;
for (
const token of [
"loadWorkspaceRepositoryList",
"createWorkspaceRepository",
"parseRepositoryListResponse",
"parseCreateWorkspaceRepositoryResponse",
"parseRepositoryApiError",
"repository.repository_key",
"repository.observed_status",
"sourceLabel(repository.source.kind)",
"supportsRepositoryAccess(repository.source.kind)",
]
) {
if (!loadSource.includes(token) && !pageSource.includes(token)) {
if (!targetSources.includes(token) && !modelSource.includes(token)) {
throw new Error(`Repository settings should include ${token}`);
}
}
if (
pageSource.includes("generated/legacy-server-api") ||
pageSource.includes("response.json()") ||
pageSource.includes(" as CreateWorkspaceRepository")
) {
throw new Error(
"Repository create UI must use the generated contract and strict API client",
);
}
for (const kind of ["ssh", "https"]) {
if (!pageSource.includes(`kind === '${kind}'`)) {
throw new Error(`Repository Access should support ${kind}`);