diff --git a/engine/core/Cargo.toml b/engine/core/Cargo.toml index 0fc18cd72..408ba23a9 100644 --- a/engine/core/Cargo.toml +++ b/engine/core/Cargo.toml @@ -20,8 +20,7 @@ fancy-regex = "0.14" rusqlite = { version = "0.32", features = ["bundled", "backup"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order", "float_roundtrip"] } -# Derives the operation schemas from the models in `application/models.rs`, -# which replaces the table recorded from pydantic for the operations they cover. +# Derives every operation schema from the models in `application/models`. schemars = "1.2.3" toml = "0.8" sha2 = "0.10" diff --git a/engine/core/src/adapters/http/openapi.rs b/engine/core/src/adapters/http/openapi.rs index 5dd864516..b327fe0f5 100644 --- a/engine/core/src/adapters/http/openapi.rs +++ b/engine/core/src/adapters/http/openapi.rs @@ -44,7 +44,8 @@ pub fn openapi() -> Value { for operation in registry.for_transport(Transport::Http) { let path = format!("/api{}", operation.http_route().path); let entry = paths.entry(path).or_insert_with(|| json!({})); - entry[method_name(operation.http_route().method)] = operation_object(operation, &mut components); + entry[method_name(operation.http_route().method)] = + operation_object(operation, &mut components); } let hand: Value = serde_json::from_str(HAND_ROUTES).expect("openapi_hand.json is JSON"); for (path, item) in hand["paths"].as_object().into_iter().flatten() { diff --git a/engine/core/src/application/models.rs b/engine/core/src/application/models.rs index 4a142c800..5fd6a788c 100644 --- a/engine/core/src/application/models.rs +++ b/engine/core/src/application/models.rs @@ -1,10 +1,8 @@ //! Parameter and result models, with the JSON Schemas derived from them. //! -//! Ports the models in `src/vogt/application/models.py` one operation at a -//! time. An operation listed in [`DERIVED`] takes its schemas from the types -//! here: `schemars` derives them, and `scripts/gen_registry.py` leaves those -//! operations out of the table it records from pydantic. Every other -//! operation keeps its recorded schema until its models are ported. +//! Ports the models in `src/vogt/application/models.py`. Every operation's +//! parameter and result schemas are derived here: `schemars` builds them from +//! these types, and [`DERIVED`] lists the operations they serve. //! //! The derived schemas are checked against Python's live `registry.dump` by //! `tests/test_rust_schemas.py`, so a Rust model that validated differently @@ -15,9 +13,167 @@ use serde::{Deserialize, Serialize}; /// The operations whose schemas are derived from the types in this module. /// -/// `scripts/gen_registry.py` reads this list to decide which schemas to -/// record, so a name added here stops being recorded on the next regeneration. -pub const DERIVED: &[&str] = &["init", "migrate", "observations.prune", "user.remove"]; +/// The registry refuses to start if an operation has no schema here, so an +/// operation is listed when its models are ported. +pub const DERIVED: &[&str] = &[ + "init", + "migrate", + "observations.prune", + "user.remove", + "chat.list", + "chat.get", + "chat.create", + "chat.send", + "chat.decide", + "chat.set_model", + "chat.interrupt", + "chat.archive", + "chat.promote", + "mcp.stdio", + "contract.adopt", + "contract.decline", + "project.scaffold", + "project.register", + "project.create", + "project.import", + "project.get", + "project.list", + "project.update", + "project.transition", + "forge.account_unlink", + "forge.account_link", + "forge.account_status", + "forge.onboard", + "session.token", + "session.log_tail", + "session.commit_for", + "workflow.list", + "label.list", + "label.create", + "preference.get", + "preference.set", + "why", + "actor.list", + "actor.create", + "user.list", + "user.create", + "user.set_password", + "token.issue", + "token.list", + "token.revoke", + "auth.whoami", + "auth.logout", + "auth.decisions", + "suppress", + "suppression.list", + "suppression.revoke", + "export", + "backup", + "restore", + "serve", + "connect", + "status", + "registry.dump", + "instance.diagnostics", + "engine.status", + "place.metrics", + "project.brief", + "work.create", + "work.get", + "work.get_many", + "work.list", + "board.list", + "work.update", + "work.transition", + "work.relate", + "work.unrelate", + "work.bind_branch", + "work.comment", + "backlog", + "bugs", + "initiative.create", + "initiative.list", + "initiative.update", + "initiative.publish", + "sweep", + "coverage", + "observations.list", + "deps", + "work.adopt", + "contract.evaluate", + "contract.check", + "contract.inapplicable", + "contract.applicable", + "compliance", + "drift.detect", + "drift.list", + "drift.resolve", + "session.start", + "session.list", + "session.stop", + "session.input", + "session.sweep", + "session.hibernate", + "session.wake", + "session.keep_awake", + "session.rename", + "session.remove", + "session.set_role", + "session.bind_work", + "session.grant_request", + "session.grant_decide", + "session.grant_revoke", + "session.grant_list", + "decision.ask", + "decision.answer", + "decision.list", + "decision.withdraw", + "session.answer", + "session.screen", + "session.last_reply", + "session.economics", + "session.digest", + "session.wait", + "session.changes", + "session.wait_any", + "session.report_blocked", + "session.report_unblocked", + "agent_cli.list", + "agent_cli.update", + "agent_activity.search", + "agent_activity.summary", + "session.history_list", + "session.search_output", + "clone", + "import", + "forge.writeback", + "forge.link", + "forge.publish", + "forge.repos", + "forge.import", + "forge.actions", + "events.list", + "notifications", + "deployed.versions", + "inbox.list", + "inbox.archive", + "inbox.snooze", + "inbox.restore", + "audit.list", +]; + +mod access; +mod admin; +mod chat; +mod entities; +mod forge; +mod project; +mod records; +mod session; +mod session_ops; +mod system; +mod views; +mod work; /// `(operation name, params schema, result schema)` for every name in /// [`DERIVED`], as `registry::dump()` emits them. @@ -39,6 +195,706 @@ pub fn derived_schemas() -> Vec<(&'static str, serde_json::Value, serde_json::Va schema_of::(), schema_of::(), ), + ( + "chat.list", + schema_of::(), + schema_of::(), + ), + ( + "chat.get", + schema_of::(), + schema_of::(), + ), + ( + "chat.create", + schema_of::(), + schema_of::(), + ), + ( + "chat.send", + schema_of::(), + schema_of::(), + ), + ( + "chat.decide", + schema_of::(), + schema_of::(), + ), + ( + "chat.set_model", + schema_of::(), + schema_of::(), + ), + ( + "chat.interrupt", + schema_of::(), + schema_of::(), + ), + ( + "chat.archive", + schema_of::(), + schema_of::(), + ), + ( + "chat.promote", + schema_of::(), + schema_of::(), + ), + ( + "mcp.stdio", + schema_of::(), + schema_of::(), + ), + ( + "contract.adopt", + schema_of::(), + schema_of::(), + ), + ( + "contract.decline", + schema_of::(), + schema_of::(), + ), + ( + "project.scaffold", + schema_of::(), + schema_of::(), + ), + ( + "project.register", + schema_of::(), + schema_of::(), + ), + ( + "project.create", + schema_of::(), + schema_of::(), + ), + ( + "project.import", + schema_of::(), + schema_of::(), + ), + ( + "project.get", + schema_of::(), + schema_of::(), + ), + ( + "project.list", + schema_of::(), + schema_of::(), + ), + ( + "project.update", + schema_of::(), + schema_of::(), + ), + ( + "project.transition", + schema_of::(), + schema_of::(), + ), + ( + "forge.account_unlink", + schema_of::(), + schema_of::(), + ), + ( + "forge.account_link", + schema_of::(), + schema_of::(), + ), + ( + "forge.account_status", + schema_of::(), + schema_of::(), + ), + ( + "forge.onboard", + schema_of::(), + schema_of::(), + ), + ( + "session.token", + schema_of::(), + schema_of::(), + ), + ( + "session.log_tail", + schema_of::(), + schema_of::(), + ), + ( + "session.commit_for", + schema_of::(), + schema_of::(), + ), + ( + "workflow.list", + schema_of::(), + schema_of::(), + ), + ( + "label.list", + schema_of::(), + schema_of::(), + ), + ( + "label.create", + schema_of::(), + schema_of::(), + ), + ( + "preference.get", + schema_of::(), + schema_of::(), + ), + ( + "preference.set", + schema_of::(), + schema_of::(), + ), + ( + "why", + schema_of::(), + schema_of::(), + ), + ( + "actor.list", + schema_of::(), + schema_of::(), + ), + ( + "actor.create", + schema_of::(), + schema_of::(), + ), + ( + "user.list", + schema_of::(), + schema_of::(), + ), + ( + "user.create", + schema_of::(), + schema_of::(), + ), + ( + "user.set_password", + schema_of::(), + schema_of::(), + ), + ( + "token.issue", + schema_of::(), + schema_of::(), + ), + ( + "token.list", + schema_of::(), + schema_of::(), + ), + ( + "token.revoke", + schema_of::(), + schema_of::(), + ), + ( + "auth.whoami", + schema_of::(), + schema_of::(), + ), + ( + "auth.logout", + schema_of::(), + schema_of::(), + ), + ( + "auth.decisions", + schema_of::(), + schema_of::(), + ), + ( + "suppress", + schema_of::(), + schema_of::(), + ), + ( + "suppression.list", + schema_of::(), + schema_of::(), + ), + ( + "suppression.revoke", + schema_of::(), + schema_of::(), + ), + ( + "export", + schema_of::(), + schema_of::(), + ), + ( + "backup", + schema_of::(), + schema_of::(), + ), + ( + "restore", + schema_of::(), + schema_of::(), + ), + ( + "serve", + schema_of::(), + schema_of::(), + ), + ( + "connect", + schema_of::(), + schema_of::(), + ), + ( + "status", + schema_of::(), + schema_of::(), + ), + ( + "registry.dump", + schema_of::(), + schema_of::(), + ), + ( + "instance.diagnostics", + schema_of::(), + schema_of::(), + ), + ( + "engine.status", + schema_of::(), + schema_of::(), + ), + ( + "place.metrics", + schema_of::(), + schema_of::(), + ), + ( + "project.brief", + schema_of::(), + schema_of::(), + ), + ( + "work.create", + schema_of::(), + schema_of::(), + ), + ( + "work.get", + schema_of::(), + schema_of::(), + ), + ( + "work.get_many", + schema_of::(), + schema_of::(), + ), + ( + "work.list", + schema_of::(), + schema_of::(), + ), + ( + "board.list", + schema_of::(), + schema_of::(), + ), + ( + "work.update", + schema_of::(), + schema_of::(), + ), + ( + "work.transition", + schema_of::(), + schema_of::(), + ), + ( + "work.relate", + schema_of::(), + schema_of::(), + ), + ( + "work.unrelate", + schema_of::(), + schema_of::(), + ), + ( + "work.bind_branch", + schema_of::(), + schema_of::(), + ), + ( + "work.comment", + schema_of::(), + schema_of::(), + ), + ( + "backlog", + schema_of::(), + schema_of::(), + ), + ( + "bugs", + schema_of::(), + schema_of::(), + ), + ( + "initiative.create", + schema_of::(), + schema_of::(), + ), + ( + "initiative.list", + schema_of::(), + schema_of::(), + ), + ( + "initiative.update", + schema_of::(), + schema_of::(), + ), + ( + "initiative.publish", + schema_of::(), + schema_of::(), + ), + ( + "sweep", + schema_of::(), + schema_of::(), + ), + ( + "coverage", + schema_of::(), + schema_of::(), + ), + ( + "observations.list", + schema_of::(), + schema_of::(), + ), + ( + "deps", + schema_of::(), + schema_of::(), + ), + ( + "work.adopt", + schema_of::(), + schema_of::(), + ), + ( + "contract.evaluate", + schema_of::(), + schema_of::(), + ), + ( + "contract.check", + schema_of::(), + schema_of::(), + ), + ( + "contract.inapplicable", + schema_of::(), + schema_of::(), + ), + ( + "contract.applicable", + schema_of::(), + schema_of::(), + ), + ( + "compliance", + schema_of::(), + schema_of::(), + ), + ( + "drift.detect", + schema_of::(), + schema_of::(), + ), + ( + "drift.list", + schema_of::(), + schema_of::(), + ), + ( + "drift.resolve", + schema_of::(), + schema_of::(), + ), + ( + "session.start", + schema_of::(), + schema_of::(), + ), + ( + "session.list", + schema_of::(), + schema_of::(), + ), + ( + "session.stop", + schema_of::(), + schema_of::(), + ), + ( + "session.input", + schema_of::(), + schema_of::(), + ), + ( + "session.sweep", + schema_of::(), + schema_of::(), + ), + ( + "session.hibernate", + schema_of::(), + schema_of::(), + ), + ( + "session.wake", + schema_of::(), + schema_of::(), + ), + ( + "session.keep_awake", + schema_of::(), + schema_of::(), + ), + ( + "session.rename", + schema_of::(), + schema_of::(), + ), + ( + "session.remove", + schema_of::(), + schema_of::(), + ), + ( + "session.set_role", + schema_of::(), + schema_of::(), + ), + ( + "session.bind_work", + schema_of::(), + schema_of::(), + ), + ( + "session.grant_request", + schema_of::(), + schema_of::(), + ), + ( + "session.grant_decide", + schema_of::(), + schema_of::(), + ), + ( + "session.grant_revoke", + schema_of::(), + schema_of::(), + ), + ( + "session.grant_list", + schema_of::(), + schema_of::(), + ), + ( + "decision.ask", + schema_of::(), + schema_of::(), + ), + ( + "decision.answer", + schema_of::(), + schema_of::(), + ), + ( + "decision.list", + schema_of::(), + schema_of::(), + ), + ( + "decision.withdraw", + schema_of::(), + schema_of::(), + ), + ( + "session.answer", + schema_of::(), + schema_of::(), + ), + ( + "session.screen", + schema_of::(), + schema_of::(), + ), + ( + "session.last_reply", + schema_of::(), + schema_of::(), + ), + ( + "session.economics", + schema_of::(), + schema_of::(), + ), + ( + "session.digest", + schema_of::(), + schema_of::(), + ), + ( + "session.wait", + schema_of::(), + schema_of::(), + ), + ( + "session.changes", + schema_of::(), + schema_of::(), + ), + ( + "session.wait_any", + schema_of::(), + schema_of::(), + ), + ( + "session.report_blocked", + schema_of::(), + schema_of::(), + ), + ( + "session.report_unblocked", + schema_of::(), + schema_of::(), + ), + ( + "agent_cli.list", + schema_of::(), + schema_of::(), + ), + ( + "agent_cli.update", + schema_of::(), + schema_of::(), + ), + ( + "agent_activity.search", + schema_of::(), + schema_of::(), + ), + ( + "agent_activity.summary", + schema_of::(), + schema_of::(), + ), + ( + "session.history_list", + schema_of::(), + schema_of::(), + ), + ( + "session.search_output", + schema_of::(), + schema_of::(), + ), + ( + "clone", + schema_of::(), + schema_of::(), + ), + ( + "import", + schema_of::(), + schema_of::(), + ), + ( + "forge.writeback", + schema_of::(), + schema_of::(), + ), + ( + "forge.link", + schema_of::(), + schema_of::(), + ), + ( + "forge.publish", + schema_of::(), + schema_of::(), + ), + ( + "forge.repos", + schema_of::(), + schema_of::(), + ), + ( + "forge.import", + schema_of::(), + schema_of::(), + ), + ( + "forge.actions", + schema_of::(), + schema_of::(), + ), + ( + "events.list", + schema_of::(), + schema_of::(), + ), + ( + "notifications", + schema_of::(), + schema_of::(), + ), + ( + "deployed.versions", + schema_of::(), + schema_of::(), + ), + ( + "inbox.list", + schema_of::(), + schema_of::(), + ), + ( + "inbox.archive", + schema_of::(), + schema_of::(), + ), + ( + "inbox.snooze", + schema_of::(), + schema_of::(), + ), + ( + "inbox.restore", + schema_of::(), + schema_of::(), + ), + ( + "audit.list", + schema_of::(), + schema_of::(), + ), ] } @@ -50,9 +906,146 @@ fn schema_of() -> serde_json::Value { if let Some(object) = value.as_object_mut() { object.remove("$schema"); } + pydantic_shape(&mut value); canonical(value) } +/// Reshapes `schemars`' output into pydantic's dialect, the one the registry +/// records: a nullable type is an `anyOf` with `null`, an integer has no +/// `format`, and every named property and definition has a title. A property +/// that is a `$ref` gets none, as pydantic's is not titled either, and a +/// title set on the type stays. +fn pydantic_shape(value: &mut serde_json::Value) { + use serde_json::{json, Value}; + match value { + Value::Object(map) => { + nullable_as_any_of(map); + if let Some(Value::String(format)) = map.get("format") { + // pydantic emits no format for an integer or a float. + if format.starts_with("int") || format.starts_with("uint") || format == "double" { + map.remove("format"); + } + } + if let Some(Value::Object(definitions)) = map.get_mut("$defs") { + for (name, schema) in definitions.iter_mut() { + if let Value::Object(definition) = schema { + definition + .entry("title") + .or_insert_with(|| Value::String(name.clone())); + } + } + } + if let Some(Value::Object(properties)) = map.get_mut("properties") { + for (name, schema) in properties.iter_mut() { + if let Value::Object(property) = schema { + // `default_factory=list` records no default, where + // serde's `default` always does: an empty-list default + // is that factory, so it is dropped. + let keep_default = property.remove("x-keep-default").is_some(); + let empty = property.get("default") == Some(&json!([])) + || property.get("default") == Some(&json!({})); + if !keep_default && empty { + property.remove("default"); + } + if !refers_to_definition(property) && !property.contains_key("title") { + property.insert("title".into(), Value::String(pydantic_title(name))); + } + } + } + } + for child in map.values_mut() { + pydantic_shape(child); + } + } + Value::Array(items) => items.iter_mut().for_each(pydantic_shape), + _ => {} + } +} + +/// Whether a schema is a reference to a definition, or an optional one. The +/// title pydantic gives a field is left off these. +fn refers_to_definition(property: &serde_json::Map) -> bool { + use serde_json::Value; + property.contains_key("$ref") + || matches!(property.get("anyOf"), Some(Value::Array(branches)) + if branches.iter().any(|branch| branch.get("$ref").is_some())) +} + +/// Rewrites a nullable schema as pydantic lays out `T | None`: an `anyOf` of +/// the value's branch and `null`. The constraints go on the value's branch; +/// the title, description and default stay beside the `anyOf`. +fn nullable_as_any_of(map: &mut serde_json::Map) { + use serde_json::Value; + let kinds = match map.get("type") { + Some(Value::Array(types)) if types.iter().any(|t| t == "null") => types + .iter() + .filter(|t| *t != "null") + .cloned() + .collect::>(), + _ => Vec::new(), + }; + let nullable_enum = + matches!(map.get("enum"), Some(Value::Array(values)) if values.iter().any(Value::is_null)); + if kinds.is_empty() && !nullable_enum { + return; + } + let mut branch = serde_json::Map::new(); + map.remove("type"); + match kinds.len() { + 0 => {} + 1 => { + branch.insert("type".to_string(), kinds[0].clone()); + } + _ => { + branch.insert("type".to_string(), Value::Array(kinds)); + } + } + if let Some(Value::Array(values)) = map.remove("enum") { + let kept: Vec = values.into_iter().filter(|v| !v.is_null()).collect(); + branch.insert("enum".to_string(), Value::Array(kept)); + } + for key in [ + "minLength", + "maxLength", + "pattern", + "minimum", + "maximum", + "format", + "items", + "exclusiveMinimum", + "exclusiveMaximum", + "minItems", + "maxItems", + ] { + if let Some(value) = map.remove(key) { + branch.insert(key.to_string(), value); + } + } + let mut null = serde_json::Map::new(); + null.insert("type".to_string(), Value::String("null".to_string())); + map.insert( + "anyOf".to_string(), + Value::Array(vec![Value::Object(branch), Value::Object(null)]), + ); +} + +/// `str.title()` of a snake-case field name, as pydantic titles a field. +fn pydantic_title(name: &str) -> String { + name.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first + .to_uppercase() + .chain(chars.as_str().to_lowercase().chars()) + .collect(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + /// Sorts the keys of every schema node, as pydantic's dump does, and keeps /// the `properties` map in declaration order, which pydantic does too. The /// dump is compared text-for-text, so the order is part of what is checked. @@ -63,10 +1056,12 @@ fn canonical(value: serde_json::Value) -> serde_json::Value { let mut entries: Vec<(String, Value)> = map .into_iter() .map(|(key, child)| { - let child = if key == "properties" { - keep_order(child) - } else { - canonical(child) + // A `default` is a value, not a schema: it keeps the order + // serde serialised it in, which is the declaration order. + let child = match key.as_str() { + "properties" => keep_order(child), + "default" => child, + _ => canonical(child), }; (key, child) }) @@ -95,10 +1090,40 @@ fn keep_order(value: serde_json::Value) -> serde_json::Value { /// `int` in Python is `"type": "integer"` with no format. `schemars` would /// add `"format": "int64"` or `"uint32"` to a Rust integer, which pydantic /// does not emit, so every integer field names this instead. -fn integer(_: &mut SchemaGenerator) -> Schema { +pub(super) fn integer(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "integer" }) } +/// pydantic's `datetime`: a string in the `date-time` format. +pub(super) fn date_time(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "string", "format": "date-time" }) +} + +/// pydantic's `float`: a JSON number, with no format. +pub(super) fn number(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "number" }) +} + +/// pydantic's `dict[str, object]`: an object of any values. +pub(super) fn open_object(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "object", "additionalProperties": true }) +} + +/// `str | None` with no default: a string, or null, and required. +pub(super) fn optional_string(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "anyOf": [{ "type": "string" }, { "type": "null" }] }) +} + +/// `dict[str, object] | None`: an object of any values, or null. +pub(super) fn optional_open_object(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "anyOf": [{ "type": "object", "additionalProperties": true }, { "type": "null" }] }) +} + +/// `datetime | None`: a `date-time` string, or null. +pub(super) fn optional_date_time(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "anyOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] }) +} + fn not_configured() -> String { "not_configured".to_string() } diff --git a/engine/core/src/application/models/access.rs b/engine/core/src/application/models/access.rs new file mode 100644 index 000000000..7802ca461 --- /dev/null +++ b/engine/core/src/application/models/access.rs @@ -0,0 +1,293 @@ +//! The access operations' parameter and result models: users, actors, +//! tokens, the caller's own identity and logout, auth decisions and +//! suppressions. +//! +//! Ports the `CreateUser*`, `SetPassword*`, `CreateActor*`, `IssueToken*`, +//! `Whoami*`, `Logout*`, `AuthDecision*`, `Suppress*` and related classes in +//! `src/vogt/application/models.py`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::entities::{ + Actor, ActorKind, AuthDecision, DecisionKind, PasswordCredential, Suppression, Token, +}; +use super::integer; + +fn list_limit_default() -> i64 { + 100 +} + +fn write_scopes_default() -> String { + "read,work.write,project.write".to_string() +} + +fn read_scope_default() -> String { + "read".to_string() +} + +fn actor_kind_default() -> String { + "agent".to_string() +} + +fn revoke_sessions_default() -> bool { + true +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListActorsParams { + #[serde(default = "list_limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 100))] + pub limit: i64, + #[serde(default)] + #[schemars(schema_with = "integer", range(min = 0))] + pub offset: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ActorListResult { + pub actors: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CreateActorParams { + /// Stable identity, e.g. agent:claude-code or local:sprooty. + #[schemars(length(min = 1))] + pub identity_ref: String, + /// human or agent. + #[serde(default = "actor_kind_default")] + #[schemars(extend("default" = "agent"))] + pub kind: String, + #[schemars(length(min = 1))] + pub display_name: String, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ActorResult { + pub actor: Actor, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(extend("properties" = {}))] +pub struct UserListParams {} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UserListResult { + pub users: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CreateUserParams { + /// Login name: lower-case letters, digits, '.', '-', '_'. + #[schemars(length(min = 1))] + pub username: String, + /// At least 8 characters. + #[schemars(length(min = 1))] + pub password: String, + /// Defaults to the username. + #[serde(default)] + #[schemars(length(min = 1))] + pub display_name: Option, + /// An existing human actor's identity_ref to attach the login to. Omitted, a new actor human: is created. + #[serde(default)] + #[schemars(length(min = 1))] + pub actor: Option, + /// Comma-separated scopes every session this user logs in to holds. + #[serde(default = "write_scopes_default")] + #[schemars(length(min = 1), extend("default" = "read,work.write,project.write"))] + pub scopes: String, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UserResult { + pub user: PasswordCredential, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SetPasswordParams { + #[schemars(length(min = 1))] + pub username: String, + /// At least 8 characters. + #[schemars(length(min = 1))] + pub password: String, + /// Replace the login's scopes as well. + #[serde(default)] + #[schemars(length(min = 1))] + pub scopes: Option, + /// Also revoke every live session this user holds. + #[serde(default = "revoke_sessions_default")] + #[schemars(extend("default" = true))] + pub revoke_sessions: bool, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IssueTokenParams { + /// Actor identity_ref the token is bound to. + #[schemars(length(min = 1))] + pub actor: String, + /// What this token is for, e.g. 'claude-code'. + #[schemars(length(min = 1))] + pub name: String, + /// Comma-separated: read, work.write, project.write, admin, writeback. + #[serde(default = "read_scope_default")] + #[schemars(length(min = 1), extend("default" = "read"))] + pub scopes: String, + /// Omit for a token that does not expire. + #[serde(default)] + #[schemars(range(min = 1, max = 3650))] + pub expires_in_days: Option, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IssueTokenResult { + pub token: Token, + /// Shown once. Not stored, not recoverable — rotate if lost. + pub secret: String, + pub warning: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListTokensParams { + #[serde(default)] + pub include_revoked: bool, + #[serde(default = "list_limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 100))] + pub limit: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenListResult { + pub tokens: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RevokeTokenParams { + pub id: String, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenResult { + pub token: Token, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(extend("properties" = {}))] +pub struct WhoamiParams {} + +/// Who the caller is, as authentication decided it. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WhoamiResult { + pub identity_ref: String, + pub kind: ActorKind, + pub display_name: String, + /// The effective scope set, implications applied. + pub scopes: Vec, + /// The token that authenticated the call; absent on the local surface. + #[serde(default)] + pub token: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LogoutParams { + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LogoutResult { + /// Whether a session token was revoked. False on a surface with no token behind it (the local CLI, a loopback listener). + pub revoked: bool, + #[serde(default)] + pub token: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AuthDecisionListParams { + /// Filter to allows or denials. + #[serde(default)] + pub decision: Option, + #[serde(default = "list_limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 100))] + pub limit: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AuthDecisionListResult { + pub decisions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SuppressParams { + /// An exact subject key, or a glob pattern when --pattern is set. + pub subject: String, + /// Treat `subject` as a glob pattern. + #[serde(default)] + pub pattern: bool, + /// Limit the suppression to one project. + #[serde(default)] + pub project: Option, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SuppressionResult { + pub suppression: Suppression, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListSuppressionsParams { + #[serde(default)] + pub include_revoked: bool, + #[serde(default = "list_limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 100))] + pub limit: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SuppressionListResult { + pub suppressions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RevokeSuppressionParams { + pub id: String, + #[schemars(length(min = 1))] + pub reason: String, +} diff --git a/engine/core/src/application/models/admin.rs b/engine/core/src/application/models/admin.rs new file mode 100644 index 000000000..ea80da4ec --- /dev/null +++ b/engine/core/src/application/models/admin.rs @@ -0,0 +1,242 @@ +//! The instance-administration operations' parameter and result models: +//! the MCP stdio bridge, export, backup, restore, serve and connect. +//! +//! Ports `McpStdio*`, `Export*`, `Backup*`, `Restore*`, `Serve*` and +//! `Connect*` in `src/vogt/application/models.py`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{date_time, integer, optional_string}; + +/// `mcp stdio` takes nothing: the streams are this process's own. +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(extend("properties" = {}))] +pub struct McpStdioParams {} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct McpStdioResult { + #[schemars(schema_with = "optional_string")] + pub protocol_version: Option, + #[schemars(schema_with = "integer")] + pub messages_handled: i64, + pub supported_protocol_versions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExportParams { + pub destination: String, + /// Export one project (by slug): its work items with their comments and relations, and only the initiatives, labels and actors they reference. Omitted, the whole instance is exported. + #[serde(default)] + pub project: Option, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExportResult { + pub path: String, + /// The file's format. 2 carries what an applying import needs. + #[schemars(schema_with = "integer")] + pub export_format_version: i64, + /// The scope, if one. + #[serde(default)] + pub project: Option, + #[schemars(schema_with = "integer")] + pub projects: i64, + #[schemars(schema_with = "integer")] + pub work_items: i64, + #[serde(default)] + #[schemars(schema_with = "integer")] + pub comments: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BackupParams { + /// Defaults to /backups/. + #[serde(default)] + pub destination: Option, + #[serde(default)] + pub label: Option, + #[schemars(length(min = 1))] + pub reason: String, +} + +/// What a backup covered, including the parts it could not. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BackupResult { + pub path: String, + pub instance_id: String, + #[schemars(schema_with = "integer")] + pub declared_schema_version: i64, + #[schemars(schema_with = "integer")] + pub observed_schema_version: i64, + #[schemars(schema_with = "date_time")] + pub taken_at: String, + /// What happened to the session engine's state directory: copied, not configured, or a failure. Stated rather than implied — a backup that quietly covered two thirds of the product would be indistinguishable from one that covered all of it until somebody restored it. + #[serde(default = "engine_state_not_configured")] + #[schemars(extend("default" = "not configured"))] + pub engine_state: String, + /// Where imported projects lived when this was taken. A restore elsewhere leaves every project pointing at a path that is not there. + #[serde(default)] + pub import_root: Option, +} + +fn engine_state_not_configured() -> String { + "not configured".to_string() +} + +fn engine_state_not_in_backup() -> String { + "not in this backup".to_string() +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RestoreParams { + /// A backup directory containing manifest.json. + pub source: String, + /// Required: this replaces the live stores. + #[serde(default)] + pub confirm: bool, + #[schemars(length(min = 1))] + pub reason: String, +} + +/// What came back, and whether the estate is still where it was. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RestoreResult { + pub source: String, + pub instance_id: String, + #[schemars(schema_with = "date_time")] + pub restored_from: String, + pub migrations_applied: Vec, + #[schemars(schema_with = "integer")] + pub declared_schema_version: i64, + /// What happened to the session engine's state directory. + #[serde(default = "engine_state_not_in_backup")] + #[schemars(extend("default" = "not in this backup"))] + pub engine_state: String, + /// Where imported projects lived when the backup was taken. + #[serde(default)] + pub import_root_then: Option, + /// Where they will be looked for now. A difference is not an error and is not corrected — the paths are in the store — but it is the reason a restored session will not open, so it is reported here rather than discovered there. + #[serde(default)] + pub import_root_now: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ServeParams { + /// Listen address. No default anywhere — it encodes exposure . The compose file supplies it. + pub host: String, + /// Listen port. No default, for the same reason as host. + #[schemars(schema_with = "integer", range(min = 1, max = 65535))] + pub port: i64, + /// Operator-owned certificate, mounted read-only. + #[serde(default)] + pub tls_cert: Option, + #[serde(default)] + pub tls_key: Option, + /// Serve without authentication. Only sane on loopback. + #[serde(default)] + pub no_auth: bool, + /// Refuse every write, whatever scope a token holds. + #[serde(default)] + pub read_only: bool, + /// Do not collect in the background. The schedule is on by default because an instance that never looks cannot tell stale evidence from none; this is the switch for a diagnostic run. + #[serde(default)] + pub no_schedule: bool, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ServeResult { + pub url: String, + pub api_path: String, + pub mcp_path: String, + pub auth_required: bool, + // Whether background collection is running. Reported rather than assumed: + // "the server is up" and "the server is looking" are different facts, and + // only one of them keeps freshness small. + #[serde(default = "collecting_default")] + #[schemars(extend("default" = true))] + pub collecting: bool, + pub writes_enabled: bool, +} + +fn collecting_default() -> bool { + true +} + +/// What a client needs in order to reach this instance. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ConnectParams { + /// `http` for a client that speaks streamable HTTP MCP — the ordinary case, and the one that needs nothing installed. `bridge` for a client that can only spawn a local process. + #[serde(default = "client_http")] + #[schemars(extend("default" = "http"))] + pub client: ClientKind, + /// `markdown` renders the connection document `DEPLOYMENT.md` describes; redirect it to CONNECTING.md if you want it on disk. + #[serde(default = "format_json")] + #[schemars(extend("default" = "json"))] + pub format: ConnectFormat, +} + +// `ClientKind` in Python: a literal, so the schema is inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum ClientKind { + #[serde(rename = "http")] + Http, + #[serde(rename = "bridge")] + Bridge, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum ConnectFormat { + #[serde(rename = "json")] + Json, + #[serde(rename = "markdown")] + Markdown, +} + +fn client_http() -> ClientKind { + ClientKind::Http +} + +fn format_json() -> ConnectFormat { + ConnectFormat::Json +} + +/// The connection facts, and the client configuration built from them. +/// +/// `url` is `None` when nobody has configured one. That is deliberately not +/// a guess: a URL the server invented would be wrong in exactly the +/// deployment this field exists for, and a client cannot tell a wrong URL +/// from an unreachable one. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ConnectResult { + #[serde(default)] + pub url: Option, + pub api_path: String, + pub mcp_path: String, + #[serde(default)] + pub mcp_url: Option, + pub supported_mcp_protocol_versions: Vec, + pub client: ClientKind, + /// Whether the client needs Vogt's own code present. False for streamable HTTP, which is why it is the recommended path. + pub requires_install: bool, + /// Ready to use: JSON for a client config, or the document. + pub configuration: String, + #[serde(default)] + pub detail: Option, +} diff --git a/engine/core/src/application/models/chat.rs b/engine/core/src/application/models/chat.rs new file mode 100644 index 000000000..0c7e1371e --- /dev/null +++ b/engine/core/src/application/models/chat.rs @@ -0,0 +1,285 @@ +//! The chat operations' parameter and result models. +//! +//! Ports the `Chat*` classes in `src/vogt/application/models.py`. Field +//! names, defaults, bounds and descriptions are the Python ones; the +//! schemas `schemars` derives from these types are what `registry.dump` +//! reports. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::integer; + +fn wait_s_default_zero() -> i64 { + 0 +} + +fn wait_s_default_sixty() -> i64 { + 60 +} + +fn tail_default() -> i64 { + 200 +} + +fn limit_default() -> i64 { + 50 +} + +fn archived_default() -> bool { + true +} + +/// Open chats (default), archived ones, or both. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum ChatArchivedFilter { + #[serde(rename = "false")] + Open, + #[serde(rename = "true")] + Archived, + #[serde(rename = "all")] + All, +} + +fn archived_filter_default() -> ChatArchivedFilter { + ChatArchivedFilter::Open +} + +/// One line of a chat. Agent text and tool results are untrusted data. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatEntryView { + #[schemars(schema_with = "integer")] + pub seq: i64, + pub at: String, + /// user, assistant, tool-call, tool-result, notice, error or approval. + pub kind: String, + pub text: String, + #[serde(default)] + pub tool_name: Option, + #[serde(default)] + pub tool_use_id: Option, + #[serde(default)] + pub is_error: bool, + /// An error the person can retry by sending the message again. + #[serde(default)] + pub retryable: bool, + #[serde(default)] + pub by: Option, +} + +/// A tool call waiting for (or decided by) a person. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatApprovalView { + pub id: String, + pub tool_name: String, + /// What the call would do: a command, a path. + pub summary: String, + /// `gate` (Vogt's pre-tool gate) or `driver` (the agent asked). + pub source: String, + /// pending, allowed, denied or expired. + pub status: String, + pub requested_at: String, + pub expires_at: String, + #[serde(default)] + pub decided_by: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatSummaryView { + pub id: String, + pub title: String, + pub driver: String, + /// The model the next turn runs on; null = default. + #[serde(default)] + pub model: Option, + pub creator: String, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub archived: bool, + #[serde(default)] + pub work_item: Option, + /// The engine session the chat continues in; it takes no more messages. + #[serde(default)] + pub promoted_session: Option, + /// idle, running or awaiting-approval. + pub state: String, + /// Whether the agent process is running now. + pub live: bool, + #[serde(default)] + pub message_count: i64, + #[serde(default)] + pub preview: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatListParams { + /// Full-text terms, matched against titles and everything said. + #[serde(default)] + pub q: Option, + /// Open chats (default), archived ones, or both. + #[serde(default = "archived_filter_default")] + #[schemars(extend("default" = "false"))] + pub archived: ChatArchivedFilter, + #[serde(default = "limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 50))] + pub limit: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatListResult { + pub chats: Vec, + /// False when the engine has chats off; the list is then empty. + pub available: bool, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatGetParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + /// The newest this many entries. + #[serde(default = "tail_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 5000), extend("default" = 200))] + pub tail: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatDetailResult { + pub chat: ChatSummaryView, + pub entries: Vec, + /// Approvals still waiting for a person. + pub approvals: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatCreateParams { + /// The first message, sent as soon as it exists. + #[serde(default)] + pub message: Option, + #[serde(default)] + pub title: Option, + /// A model the engine offers (/api/config `chat`); default: its first. + #[serde(default)] + pub model: Option, + /// e.g. WI-7 + #[serde(default)] + pub work_item: Option, + /// Wait up to this long for the first reply. + #[serde(default = "wait_s_default_zero")] + #[schemars(schema_with = "integer", range(min = 0, max = 300), extend("default" = 0))] + pub wait_s: i64, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatSendParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + #[schemars(length(min = 1, max = 65536))] + pub text: String, + /// Wait up to this long for the turn to end and return what it said. A turn parked on an approval waits for a person. + #[serde(default = "wait_s_default_sixty")] + #[schemars(schema_with = "integer", range(min = 0, max = 300), extend("default" = 60))] + pub wait_s: i64, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatSendResult { + pub chat: ChatSummaryView, + /// What the chat recorded from this message on. + pub entries: Vec, + /// Whether the turn ended within the wait. + pub finished: bool, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatDecideParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + pub approval_id: String, + pub allow: bool, + /// Told to the agent with a denial. + #[serde(default)] + pub message: Option, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatSetModelParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + /// A model the engine offers, or `default` for the driver's own. + pub model: String, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatIdParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatArchiveParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + #[serde(default = "archived_default")] + #[schemars(extend("default" = true))] + pub archived: bool, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatPromoteParams { + #[schemars(description = "The chat's id (a UUID, from chat.list or chat.create).")] + pub id: String, + /// Where the session opens, inside the engine's workspace. + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub work_item: Option, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChatPromoteResult { + pub chat: ChatSummaryView, + /// The terminal session now holding the conversation. + pub engine_session_id: String, + pub session_name: String, +} diff --git a/engine/core/src/application/models/entities.rs b/engine/core/src/application/models/entities.rs new file mode 100644 index 000000000..f88ffe1e5 --- /dev/null +++ b/engine/core/src/application/models/entities.rs @@ -0,0 +1,515 @@ +//! The entity views the operations return: labels, actors, logins, tokens, +//! auth decisions, suppressions, workflows, preferences and ranking +//! contributions. +//! +//! Ports the `Label`, `Actor`, `PasswordCredential`, `Token`, `AuthDecision` +//! and `Suppression` entities from `src/vogt/core/entities.py`, and the +//! views from `src/vogt/application/models.py`. They are shared by several +//! operations, which is why they live here rather than beside one. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{date_time, integer, number, open_object, optional_date_time}; + +fn token_kind_api() -> TokenKind { + TokenKind::Api +} + +// `ActorKind` in Python: a literal, so the schema is inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum ActorKind { + #[serde(rename = "human")] + Human, + #[serde(rename = "agent")] + Agent, +} + +// `Literal["api", "session", "agent"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum TokenKind { + #[serde(rename = "api")] + Api, + #[serde(rename = "session")] + Session, + #[serde(rename = "agent")] + Agent, +} + +// `Literal["allow", "deny"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum DecisionKind { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, +} + +// `Literal["exact", "pattern"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[schemars(inline)] +pub enum MatchKind { + #[serde(rename = "exact")] + Exact, + #[serde(rename = "pattern")] + Pattern, +} + +/// A free-form tag, shared per instance and GitHub-label aligned. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Label { + pub id: String, + pub name: String, + #[serde(default)] + pub color: Option, + #[schemars(schema_with = "date_time")] + pub created_at: String, +} + +/// A human or an agent. Both are first-class. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Actor { + pub id: String, + pub kind: ActorKind, + pub display_name: String, + pub identity_ref: String, + #[serde(default)] + pub disabled: bool, + #[schemars(schema_with = "date_time")] + pub created_at: String, +} + +/// A human's login: a username, the scopes a login grants, and nothing else. +/// +/// The hash is deliberately not on the model. It is written and read by the +/// storage layer alone, so no listing, result or audit payload can carry it. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PasswordCredential { + pub actor_id: String, + #[serde(default)] + pub actor_identity_ref: Option, + pub username: String, + pub scopes: Vec, + #[schemars(schema_with = "date_time")] + pub created_at: String, + #[schemars(schema_with = "date_time")] + pub updated_at: String, +} + +/// A scoped credential bound to an actor. +/// +/// Never carries the secret: only the hash is stored, and the secret is +/// shown once at issue time. A model that could round-trip the credential +/// is a model that leaks it into logs and API responses. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Token { + pub id: String, + pub actor_id: String, + #[serde(default)] + pub actor_identity_ref: Option, + pub name: String, + pub scopes: Vec, + // `api` for a minted or adopted token, `session` for one a password login + // produced (expiring, revoked by `auth.logout`), `agent` for a coding + // session's own token. Checked identically at authentication. + #[serde(default = "token_kind_api")] + #[schemars(extend("default" = "api"))] + pub kind: TokenKind, + #[schemars(schema_with = "date_time")] + pub created_at: String, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub expires_at: Option, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub last_used_at: Option, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub revoked_at: Option, + #[serde(default)] + pub revoked_reason: Option, +} + +/// One allow or deny, recorded. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AuthDecision { + pub id: String, + #[schemars(schema_with = "date_time")] + pub at: String, + pub decision: DecisionKind, + pub reason_code: String, + pub operation: String, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub actor_id: Option, + #[serde(default)] + pub token_id: Option, + #[serde(default)] + pub identity_ref: Option, + pub transport: String, + #[serde(default)] + pub detail: Option, +} + +/// An audited decision that an observed subject is not work. +/// +/// Lives in the declared store because it is a decision, not an +/// observation — which is exactly why it survives re-observation of the same +/// subject, and why a dismissal recorded in the evidence store could never +/// have worked. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Suppression { + pub id: String, + pub match_kind: MatchKind, + pub subject_key_or_pattern: String, + #[serde(default)] + pub scope_project_id: Option, + #[serde(default)] + pub scope_project_slug: Option, + pub actor_id: String, + #[serde(default)] + pub actor_identity_ref: Option, + pub reason: String, + #[schemars(schema_with = "date_time")] + pub created_at: String, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub revoked_at: Option, + #[serde(default)] + pub revoked_reason: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkflowView { + pub kind: String, + pub initial_state: String, + pub states: Vec, + pub transitions: BTreeMap>, +} + +/// One of the caller's own settings. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PreferenceView { + pub key: String, + #[schemars(schema_with = "open_object")] + pub value: serde_json::Map, + #[schemars(schema_with = "integer")] + pub version: i64, + #[schemars(schema_with = "date_time")] + pub updated_at: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ContributionView { + pub input: String, + pub detail: String, + #[schemars(schema_with = "number")] + pub value: f64, + #[schemars(schema_with = "number")] + pub weight: f64, + #[schemars(schema_with = "number")] + pub contribution: f64, +} + +// `Literal["incubating", "active", "maintenance", "archived"]` in Python. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum LifecycleState { + Incubating, + Active, + Maintenance, + Archived, +} + +// `Literal["compliant", "non_compliant", "not_checked", "not_applicable"]`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum ComplianceStatus { + Compliant, + NonCompliant, + NotChecked, + NotApplicable, +} + +// `Literal["none", "comment_only", "full"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum WriteBack { + None, + CommentOnly, + Full, +} + +// `Literal["unlinked", "linked"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum LinkState { + Unlinked, + Linked, +} + +// `Literal["verified", "stale", "unverified", "disputed"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum TrustState { + Verified, + Stale, + Unverified, + Disputed, +} + +// `Literal["push_branch", "open_pr"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum WorkerLandsBy { + PushBranch, + OpenPr, +} + +// `Literal["oversight", "operator"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum MergeBy { + Oversight, + Operator, +} + +// `Literal["fast_forward", "rebase"]` in Python, inline. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum MergeMode { + FastForward, + Rebase, +} + +pub(super) fn lifecycle_active() -> LifecycleState { + LifecycleState::Active +} + +pub(super) fn compliance_not_checked() -> ComplianceStatus { + ComplianceStatus::NotChecked +} + +pub(super) fn write_back_none() -> WriteBack { + WriteBack::None +} + +pub(super) fn link_unlinked() -> LinkState { + LinkState::Unlinked +} + +pub(super) fn trust_unverified() -> TrustState { + TrustState::Unverified +} + +pub(super) fn branch_pattern_default() -> String { + "^wi-{n}-[a-z0-9-]+$".to_string() +} + +pub(super) fn operator_default() -> String { + "operator".to_string() +} + +pub(super) fn default_true() -> bool { + true +} + +pub(super) fn review_template() -> String { + "claude".to_string() +} + +pub(super) fn review_model() -> String { + "opus".to_string() +} + +pub(super) fn review_effort() -> String { + "low".to_string() +} + +pub(super) fn review_budget_turns() -> i64 { + 40 +} + +/// One explicitly registered repository or folder. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Project { + pub id: String, + pub slug: String, + pub name: String, + pub root_path: String, + #[serde(default)] + pub repo_url: Option, + #[serde(default = "lifecycle_active")] + pub lifecycle_state: LifecycleState, + #[serde(default)] + pub current_version: Option, + #[serde(default)] + pub contract_version: Option, + #[serde(default = "compliance_not_checked")] + pub compliance_status: ComplianceStatus, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub compliance_checked_at: Option, + #[serde(default)] + pub agent_policy: AgentPolicy, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub contract_adopted_at: Option, + #[serde(default = "write_back_none")] + pub write_back: WriteBack, + #[serde(default = "link_unlinked")] + pub link_state: LinkState, + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub exclusions: Vec, + #[serde(default = "trust_unverified")] + pub trust_state: TrustState, + #[schemars(schema_with = "date_time")] + pub created_at: String, + #[schemars(schema_with = "date_time")] + pub updated_at: String, +} + +/// A project's agent policy, stored as data and shown in every session brief. +/// +/// `forbidden_text` and `merge_deploys_prod` are the two facts the session +/// hooks refuse on. `landing`, `review` and `verification` are recorded so +/// the brief can state them; nothing enforces them yet. Everything else is +/// guidance the brief carries and nothing enforces: compliance stays a fact +/// to read, never a precondition. +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AgentPolicy { + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub forbidden_text: Vec, + #[serde(default)] + pub merge_deploys_prod: bool, + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub main_branches: Vec, + #[serde(default)] + pub base_branch: String, + #[serde(default)] + pub commit_identity: String, + #[serde(default)] + pub test_recipe: String, + #[serde(default)] + pub deployed_setup: String, + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub rules: Vec, + #[serde(default)] + pub session_wall_clock_minutes: Option, + #[serde(default)] + pub landing: Option, + #[serde(default)] + pub review: Option, + #[serde(default)] + pub verification: Option, +} + +/// How a worker's branch is cut and landed on this project. +/// +/// Recorded so a brief can state the rule and, later, so the engine can +/// enforce it. Nothing here is enforced yet: a later item reads it. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LandingPolicy { + pub base_branch: String, + #[serde(default = "branch_pattern_default")] + pub branch_pattern: String, + pub worker_lands_by: WorkerLandsBy, + pub merge_by: MergeBy, + pub merge_mode: MergeMode, + #[serde(default = "default_true")] + pub linear_history: bool, + #[serde(default = "operator_default")] + pub deploy_by: String, +} + +/// How a review of this project's work is run. Guidance until enforced. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReviewPolicy { + #[serde(default = "review_template")] + pub template: String, + #[serde(default = "review_model")] + pub model: String, + #[serde(default = "review_effort")] + pub effort: String, + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub never_models: Vec, + #[serde(default = "review_budget_turns")] + pub budget_turns: i64, +} + +/// Whether closing an item needs a verification record, and where. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VerificationPolicy { + #[serde(default)] + pub required_for_done: bool, + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub lanes: Vec, +} + +// `Literal["fresh", "never_swept", "partial"]` in Python; a fresh view takes never_swept. +#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[schemars(inline)] +pub enum FreshnessStatus { + Fresh, + #[default] + NeverSwept, + Partial, +} + +/// How old the evidence behind an answer is. +/// +/// Every aggregating answer carries one. `oldest_relevant_sweep` is the +/// honest number: an answer is exactly as fresh as the least fresh thing it +/// depends on. +#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Freshness { + #[serde(default)] + pub status: FreshnessStatus, + #[serde(default)] + #[schemars(schema_with = "optional_date_time")] + pub oldest_relevant_sweep: Option, + #[serde(default)] + pub age_seconds: Option, + /// Per collector: how long ago it last completed. + #[serde(default)] + #[schemars(extend("x-keep-default" = true))] + pub collectors: BTreeMap, + #[serde(default)] + pub detail: Option, +} diff --git a/engine/core/src/application/models/forge.rs b/engine/core/src/application/models/forge.rs new file mode 100644 index 000000000..97edc66f4 --- /dev/null +++ b/engine/core/src/application/models/forge.rs @@ -0,0 +1,136 @@ +//! The forge operations' parameter and result models: account link and +//! unlink, account status and onboarding. +//! +//! Ports `ForgeAccount*` and `Onboard*` in `src/vogt/application/models.py`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{integer, optional_string}; + +fn github_host() -> String { + "github.com".to_string() +} + +fn max_pages_default() -> i64 { + 10 +} + +fn supported_default() -> bool { + true +} + +/// One linked account, never carrying the token. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountView { + pub host: String, + pub login: String, + /// The token's granted scopes as the forge reports them (`X-OAuth-Scopes`), or empty when the forge did not say. + pub scopes: String, + #[serde(default = "supported_default")] + #[schemars(extend("default" = true))] + pub linked: bool, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountUnlinkParams { + /// The forge host to unlink. Deletes the stored token. + #[serde(default = "github_host")] + #[schemars(extend("default" = "github.com"))] + pub host: String, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +/// The outcome of a link or unlink — status only, never the token. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountResult { + pub host: String, + /// The linked login; null once unlinked. + #[serde(default)] + pub login: Option, + #[serde(default)] + pub scopes: String, + pub linked: bool, +} + +/// Link the acting actor's own forge account by pasting a PAT. +/// +/// The token is validated against the forge, then stored encrypted at rest +/// and never echoed. Linking arms upstream writes attributed to *you*: the +/// scope of what those writes can do is the scope of the token you paste. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountLinkParams { + /// The configured forge host to link, for example github.com or a Forgejo host named under forge_token_files. + #[serde(default = "github_host")] + #[schemars(extend("default" = "github.com"))] + pub host: String, + /// The Personal Access Token. Validated, then stored encrypted at rest under `forge_account_key_file` and never returned by any surface. To revoke it, unlink here and revoke it upstream too. + #[schemars(length(min = 1))] + pub token: String, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountStatusParams { + /// Restrict to one host; unset lists every host you linked. + #[serde(default)] + pub host: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ForgeAccountStatusResult { + pub accounts: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OnboardParams { + /// Project slug to consolidate. + pub project: String, + /// How far back to walk. Bounded so a busy repository cannot turn onboarding into an unbounded run; coverage reports what was actually swept. + #[serde(default = "max_pages_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 100), extend("default" = 10))] + pub max_pages: i64, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OnboardResult { + pub project: String, + #[schemars(schema_with = "optional_string")] + pub repo: Option, + #[schemars(schema_with = "integer")] + pub issues: i64, + #[schemars(schema_with = "integer")] + pub pull_requests: i64, + #[schemars(schema_with = "integer")] + pub labels: i64, + #[schemars(schema_with = "integer")] + pub releases: i64, + #[schemars(schema_with = "integer")] + pub new: i64, + #[schemars(schema_with = "integer")] + pub unchanged: i64, + /// Always zero. Onboarding is read-only; the field exists so the claim is asserted rather than assumed. + #[serde(default)] + #[schemars(schema_with = "integer")] + pub mutations: i64, + /// Whether an adapter could read this repository's host at all. False makes the zeros below meaningless rather than informative: nothing was read, so nothing was found, and `detail` says why. The import playbook treats an empty consolidation as a signal, and this is what makes that signal readable. + #[serde(default = "supported_default")] + #[schemars(extend("default" = true))] + pub supported: bool, + #[serde(default)] + pub detail: Option, +} diff --git a/engine/core/src/application/models/project.rs b/engine/core/src/application/models/project.rs new file mode 100644 index 000000000..53fb85566 --- /dev/null +++ b/engine/core/src/application/models/project.rs @@ -0,0 +1,246 @@ +//! The contract, scaffold and project operations' parameter and result models. +//! +//! Ports the `ContractAdopt*`, `ScaffoldProject*` classes in +//! `src/vogt/application/models.py`. `contract.decline` shares the adopt +//! models, as it does in Python. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::entities::{default_true, lifecycle_active, AgentPolicy, LifecycleState, Project}; +use super::forge::OnboardResult; +use super::optional_date_time; + +/// Opt a registered project into the contract. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ContractAdoptParams { + /// A registered project slug. + pub project: String, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ContractAdoptResult { + pub project: String, + pub adopted: bool, + #[schemars(schema_with = "optional_date_time")] + #[schemars(required)] + pub adopted_at: Option, + /// What compliance reports for this project after the change. + pub status: String, + pub detail: String, +} + +/// Lay the contract's skeleton into an already-registered project. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ScaffoldProjectParams { + /// A registered project slug. + pub project: String, + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ScaffoldProjectResult { + pub project: String, + pub root_path: String, + /// Paths written, relative to the project's root. + pub created: Vec, + /// Paths already present and therefore left exactly as they were. + pub skipped: Vec, + pub detail: String, +} + +fn lifecycle_incubating() -> LifecycleState { + LifecycleState::Incubating +} + +fn list_limit() -> i64 { + 50 +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RegisterProjectParams { + /// Display name; the slug is derived from it. + #[schemars(length(min = 1))] + pub name: String, + /// Folder or git repository this project is. + pub root_path: String, + /// Optional remote the project is published at. + #[serde(default)] + pub repo_url: Option, + /// incubating / active / maintenance / archived. + #[serde(default = "lifecycle_active")] + pub lifecycle_state: LifecycleState, + /// Paths collection skips, replacing the defaults entirely rather than adding to them. Omit for the defaults. A repository that vendors a corpus or carries agent worktrees needs this at registration, because the first sweep is the baseline every later answer is compared against. + #[serde(default)] + pub exclusions: Option>, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProjectResult { + pub project: Project, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CreateProjectParams { + /// Display name; the slug is derived from it. + #[schemars(length(min = 1))] + pub name: String, + /// Directory to scaffold into. Created if absent; existing files are never overwritten. + pub root_path: String, + /// Recorded in the scaffold. Defaults to the acting principal. + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub repo_url: Option, + #[serde(default = "lifecycle_incubating")] + pub lifecycle_state: LifecycleState, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CreateProjectResult { + pub project: Project, + /// Files and directories written. Existing ones are left alone. + pub created_paths: Vec, + /// Contract paths that already existed and were not touched. + pub skipped_paths: Vec, +} + +/// Bring a repository that lives on a configured forge into Vogt. +/// +/// `repo` is named by the caller and is never chosen from a list: there is +/// no listing operation, and adding one would be the registration-candidate +/// listing that was deliberately removed. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ImportProjectParams { + /// Repository to import: `owner/name`, `host/owner/name`, or an HTTPS/SSH repository URL for GitHub or a configured Forgejo host. + #[schemars(length(min = 1))] + pub repo: String, + /// Display name. Defaults to the repository's own name. + #[serde(default)] + pub name: Option, + /// Where to clone to. Defaults to `/`; supply this only when one repository must live somewhere specific. + #[serde(default)] + pub root_path: Option, + #[serde(default = "lifecycle_active")] + pub lifecycle_state: LifecycleState, + /// Read existing issues, PRs, labels and releases after registering. Read-only, and on by default because a project that arrives empty looks like a project with no work. + #[serde(default = "default_true")] + pub consolidate: bool, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ImportProjectResult { + pub project: Project, + /// The remote that was cloned, without credentials. + pub remote: String, + pub root_path: String, + /// HEAD at the moment of import. + #[serde(default)] + pub revision: Option, + #[serde(default)] + pub default_branch: Option, + /// False when the destination already held a clone of the same remote and was registered as it stood. + #[serde(default = "default_true")] + pub cloned: bool, + /// What the read-only consolidation found, if it ran. + #[serde(default)] + pub consolidated: Option, + #[serde(default)] + pub detail: Option, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct GetProjectParams { + /// Project slug. + pub slug: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListProjectsParams { + #[serde(default = "list_limit")] + #[schemars(range(min = 1, max = 500))] + pub limit: i64, + #[serde(default)] + #[schemars(range(min = 0))] + pub offset: i64, +} + +/// A project, plus whether `work.create` would land there right now. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars(extend("additionalProperties" = false))] +pub struct ProjectListing { + #[serde(flatten)] + pub project: Project, + /// Whether a default `work.create` (no `local_only`) succeeds on this project now: it is forge-linked, its write-back policy permits `create`, a forge credential resolves and its repo_url parses. False means the create refuses with `project_not_linked` or `upstream_write_refused`; `writable_reason` says which and how to fix it. + pub writable: bool, + /// Why `writable` is what it is, naming the way forward. + pub writable_reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProjectListResult { + pub projects: Vec, + pub total: i64, +} + +/// Correct a project's declaration after registration. +/// +/// Deliberately narrow. `lifecycle_state` is absent because it has its own +/// operation with a validated edge (`project transition`), and the observed +/// fields are absent because nothing declares them. What is left is what a +/// registration can get wrong and nothing else could fix — where the project +/// is published, what collection should skip — and the agent policy, which is +/// data the project states rather than something derived. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UpdateProjectParams { + /// Project to update. + pub slug: String, + /// Leave unset to keep the current value. + #[serde(default)] + pub repo_url: Option, + /// Replaces the current list entirely. Leave unset to keep it; pass an empty list to collect everything. + #[serde(default)] + pub exclusions: Option>, + /// Replaces the project's agent policy entirely. Leave unset to keep it. `forbidden_text` entries are regexes and must compile, and so must `landing.branch_pattern` once `{n}` stands for a number; `landing.merge_mode` is `fast_forward` or `rebase` — `merge` is refused. A pattern that does not compile is refused rather than stored. + #[serde(default)] + pub agent_policy: Option, + /// Why this write is being made (audited). + #[schemars(length(min = 1))] + pub reason: String, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TransitionProjectParams { + pub slug: String, + /// Target lifecycle state. + pub to_state: LifecycleState, + #[schemars(length(min = 1))] + pub reason: String, +} diff --git a/engine/core/src/application/models/records.rs b/engine/core/src/application/models/records.rs new file mode 100644 index 000000000..a1630b649 --- /dev/null +++ b/engine/core/src/application/models/records.rs @@ -0,0 +1,125 @@ +//! The record operations' parameter and result models: labels, the caller's +//! preferences, workflows and the ranking explanation. +//! +//! Ports the `ListLabels*`, `CreateLabel*`, `Preference*`, `Workflow*` and +//! `Why*` classes in `src/vogt/application/models.py`. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::entities::{ContributionView, Label, PreferenceView, WorkflowView}; +use super::{integer, number, open_object}; + +fn label_limit_default() -> i64 { + 100 +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(extend("properties" = {}))] +pub struct WorkflowListParams {} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkflowListResult { + pub workflows: Vec, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListLabelsParams { + #[serde(default = "label_limit_default")] + #[schemars(schema_with = "integer", range(min = 1, max = 500), extend("default" = 100))] + pub limit: i64, + #[serde(default)] + #[schemars(schema_with = "integer", range(min = 0))] + pub offset: i64, +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct LabelListResult { + pub labels: Vec